mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-07 13:56:29 +00:00
- modelregistry: ModelInfo (description, purpose, tags, column docs) with external JSON loader, Describer fallback and gorm/bun/comment tag support - resolvemcp: surface descriptions in list_tables and describe_table, send a usage guide as MCP server instructions, add package docs - add BuildCatalog/ExportCatalog to write a JSON or Markdown API catalogue - document the descriptions map and catalogue in the README
173 lines
5.5 KiB
Go
173 lines
5.5 KiB
Go
package modelregistry
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
)
|
|
|
|
// ModelInfo is human/AI-facing documentation for a registered model: what it
|
|
// is for and what its columns mean. It is optional and has no effect on
|
|
// permissions or queries.
|
|
type ModelInfo struct {
|
|
// Description says what the model/table holds.
|
|
Description string `json:"description,omitempty"`
|
|
// Purpose says why it exists / when an agent should use it.
|
|
Purpose string `json:"purpose,omitempty"`
|
|
// Tags are free-form labels (e.g. "billing", "pii").
|
|
Tags []string `json:"tags,omitempty"`
|
|
// Columns maps a JSON column name to its description.
|
|
Columns map[string]string `json:"columns,omitempty"`
|
|
}
|
|
|
|
// IsZero reports whether the info carries no documentation.
|
|
func (i ModelInfo) IsZero() bool {
|
|
return i.Description == "" && i.Purpose == "" && len(i.Tags) == 0 && len(i.Columns) == 0
|
|
}
|
|
|
|
// Describer can be implemented by a model to document itself. It is the
|
|
// fallback used when no ModelInfo description was registered or loaded.
|
|
// (The method is not called Description so models may keep a Description field.)
|
|
type Describer interface {
|
|
ModelDescription() string
|
|
}
|
|
|
|
// commentTagKeys are the standalone struct tags read as a column description,
|
|
// in priority order.
|
|
var commentTagKeys = []string{"comment", "note", "desc", "description"}
|
|
|
|
// FieldComment returns the description of a struct field from its tags. Order:
|
|
// standalone comment/note/desc/description tags, then a "comment:" entry inside
|
|
// the gorm tag (semicolon separated), then inside the bun tag (comma separated).
|
|
// It returns "" when the field carries none.
|
|
func FieldComment(sf reflect.StructField) string {
|
|
for _, key := range commentTagKeys {
|
|
if v := strings.TrimSpace(sf.Tag.Get(key)); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
if v := tagOption(sf.Tag.Get("gorm"), ';', "comment:"); v != "" {
|
|
return v
|
|
}
|
|
return tagOption(sf.Tag.Get("bun"), ',', "comment:")
|
|
}
|
|
|
|
func tagOption(tag string, sep byte, key string) string {
|
|
for _, part := range strings.Split(tag, string(sep)) {
|
|
part = strings.TrimSpace(part)
|
|
if len(part) >= len(key) && strings.EqualFold(part[:len(key)], key) {
|
|
return strings.Trim(strings.TrimSpace(part[len(key):]), `'"`)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SetModelInfo stores documentation for a model name ("schema.entity"). The
|
|
// model does not have to be registered yet, so descriptions can be loaded
|
|
// before or after registration. Any previous info for the name is replaced.
|
|
func (r *DefaultModelRegistry) SetModelInfo(name string, info ModelInfo) {
|
|
r.mutex.Lock()
|
|
defer r.mutex.Unlock()
|
|
if r.info == nil {
|
|
r.info = make(map[string]ModelInfo)
|
|
}
|
|
r.info[name] = cloneInfo(info)
|
|
}
|
|
|
|
// GetModelInfo returns the documentation stored with SetModelInfo (or loaded
|
|
// from a descriptions file), without any fallback.
|
|
func (r *DefaultModelRegistry) GetModelInfo(name string) (ModelInfo, bool) {
|
|
r.mutex.RLock()
|
|
defer r.mutex.RUnlock()
|
|
info, ok := r.info[name]
|
|
return cloneInfo(info), ok
|
|
}
|
|
|
|
// RegisterModelWithInfo registers a model together with its documentation.
|
|
func (r *DefaultModelRegistry) RegisterModelWithInfo(name string, model interface{}, info ModelInfo) error {
|
|
if err := r.RegisterModel(name, model); err != nil {
|
|
return err
|
|
}
|
|
r.SetModelInfo(name, info)
|
|
return nil
|
|
}
|
|
|
|
// ResolveModelInfo returns the effective documentation for a registered model.
|
|
// Stored/loaded info wins; an empty Description falls back to the model's
|
|
// Describer. Column descriptions are not resolved here: use the stored map and
|
|
// fall back to FieldComment per field.
|
|
func (r *DefaultModelRegistry) ResolveModelInfo(name string) ModelInfo {
|
|
info, _ := r.GetModelInfo(name)
|
|
if info.Description == "" {
|
|
if model, err := r.GetModel(name); err == nil {
|
|
info.Description = describerText(model)
|
|
}
|
|
}
|
|
return info
|
|
}
|
|
|
|
func describerText(model interface{}) (text string) {
|
|
defer func() {
|
|
if recover() != nil {
|
|
text = ""
|
|
}
|
|
}()
|
|
if d, ok := model.(Describer); ok {
|
|
return strings.TrimSpace(d.ModelDescription())
|
|
}
|
|
if t := reflect.TypeOf(model); t != nil && t.Kind() != reflect.Pointer {
|
|
if d, ok := reflect.New(t).Interface().(Describer); ok {
|
|
return strings.TrimSpace(d.ModelDescription())
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// LoadModelInfo reads an external descriptions map from r and applies it. The
|
|
// JSON is an object keyed by model name:
|
|
//
|
|
// {"public.users": {"description": "...", "purpose": "...", "tags": ["x"],
|
|
// "columns": {"email": "Login address"}}}
|
|
//
|
|
// Entries replace any existing info for the same name and take precedence over
|
|
// the model's Describer and its struct-tag comments. It returns the number of
|
|
// models loaded.
|
|
func (r *DefaultModelRegistry) LoadModelInfo(src io.Reader) (int, error) {
|
|
var m map[string]ModelInfo
|
|
dec := json.NewDecoder(src)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&m); err != nil {
|
|
return 0, fmt.Errorf("modelregistry: decode model info: %w", err)
|
|
}
|
|
for name, info := range m {
|
|
r.SetModelInfo(name, info)
|
|
}
|
|
return len(m), nil
|
|
}
|
|
|
|
// LoadModelInfoFile is LoadModelInfo reading from a JSON file.
|
|
func (r *DefaultModelRegistry) LoadModelInfoFile(path string) (int, error) {
|
|
f, err := os.Open(filepath.Clean(path)) //nolint:gosec // operator-supplied descriptions file
|
|
if err != nil {
|
|
return 0, fmt.Errorf("modelregistry: %w", err)
|
|
}
|
|
defer f.Close()
|
|
return r.LoadModelInfo(f)
|
|
}
|
|
|
|
func cloneInfo(in ModelInfo) ModelInfo {
|
|
out := in
|
|
out.Tags = append([]string(nil), in.Tags...)
|
|
if in.Columns != nil {
|
|
out.Columns = make(map[string]string, len(in.Columns))
|
|
for k, v := range in.Columns {
|
|
out.Columns[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|