mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-07 22:06:28 +00:00
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
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user