Files
ResolveSpec/pkg/modelregistry/info.go
T
Hein 431b674162 feat(resolvemcp): add model descriptions, usage guide and catalogue export
- 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
2026-10-07 14:09:37 +02:00

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
}