package coordinator import ( "context" "fmt" "strings" "chat-agent-system/internal/contracts/vocabulary" ) // ResolvedDoc is a uniquely resolved controlled document. type ResolvedDoc struct { DocID string Title string State vocabulary.DocumentState TaskKey string } // DocListItem is one candidate from the project document inventory. type DocListItem struct { DocID string Title string State vocabulary.DocumentState TaskKey string Aliases []string } // DocLister lists workflow documents for a project (typically ListWorkflowDocumentsByProject). type DocLister func(ctx context.Context, projectID int64) ([]DocListItem, error) // DocResolver normalizes target_doc references; never silently picks the first match. type DocResolver interface { ResolveDoc(ctx context.Context, projectID int64, ref, activeDocID string) (doc *ResolvedDoc, ok bool, candidates []string, err error) } // ListDocResolver resolves against a DocLister inventory. type ListDocResolver struct { List DocLister } // ResolveDoc matches by exact doc_id, then unique title/alias; empty/@active uses activeDocID. // 0 or multiple title matches → ok=false with candidate labels (never default to first). func (r *ListDocResolver) ResolveDoc(ctx context.Context, projectID int64, ref, activeDocID string) (*ResolvedDoc, bool, []string, error) { if r == nil || r.List == nil { return nil, false, nil, fmt.Errorf("doc resolver not configured") } ref = strings.TrimSpace(ref) activeDocID = strings.TrimSpace(activeDocID) if ref == "" || ref == "@active" || strings.EqualFold(ref, "当前受控文档") || ref == "当前文档" { if activeDocID == "" { items, err := r.List(ctx, projectID) if err != nil { return nil, false, nil, err } return nil, false, candidateLabels(items), nil } ref = activeDocID } items, err := r.List(ctx, projectID) if err != nil { return nil, false, nil, err } if len(items) == 0 { return nil, false, nil, nil } for _, it := range items { if strings.EqualFold(it.DocID, ref) { return toResolved(it), true, nil, nil } } normRef := normalizeDocLabel(ref) var hits []DocListItem for _, it := range items { if normalizeDocLabel(it.Title) == normRef || normalizeDocLabel(it.TaskKey) == normRef { hits = append(hits, it) continue } for _, a := range it.Aliases { if normalizeDocLabel(a) == normRef { hits = append(hits, it) break } } // loose contains only when unique later if strings.Contains(normalizeDocLabel(it.Title), normRef) || strings.Contains(normRef, normalizeDocLabel(it.Title)) { if normalizeDocLabel(it.Title) != "" && normRef != "" { already := false for _, h := range hits { if h.DocID == it.DocID { already = true break } } if !already { hits = append(hits, it) } } } } if len(hits) == 1 { return toResolved(hits[0]), true, nil, nil } if len(hits) == 0 { return nil, false, candidateLabels(items), nil } return nil, false, candidateLabels(hits), nil } func toResolved(it DocListItem) *ResolvedDoc { return &ResolvedDoc{DocID: it.DocID, Title: it.Title, State: it.State, TaskKey: it.TaskKey} } func candidateLabels(items []DocListItem) []string { out := make([]string, 0, len(items)) for _, it := range items { label := it.Title if label == "" { label = it.DocID } if it.DocID != "" && it.Title != "" { label = fmt.Sprintf("%s(%s)", it.Title, it.DocID) } out = append(out, label) } return out } func normalizeDocLabel(s string) string { s = strings.TrimSpace(s) s = strings.Trim(s, "《》「」\"' ") s = strings.ToLower(s) s = strings.ReplaceAll(s, " ", "") s = strings.ReplaceAll(s, " ", "") return s } // PendingSupplementView is a read-only pending ledger row for dialogue answers. type PendingSupplementView struct { DocID string DocTitle string SourceSummary string ImpactReason string CreatedAt string } // PendingSupplementReader lists undigested locked-period supplements. type PendingSupplementReader interface { ListPendingSupplements(ctx context.Context, projectID int64, docID string) ([]PendingSupplementView, error) } // ReopenListItem is a pending reopen request shown for approve selection. type ReopenListItem struct { RequestID int64 DocTasks []string Reason string Status string } // ReopenLister lists pending reopen requests for a project (approve path). type ReopenLister interface { ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenListItem, error) }