Files
Agent-System/internal/controlcenter/server/docs_handlers.go
T
weichen 63f1fa5f87 feat(control-center): P2 document vector store — ingestion, chunking, embedding, hybrid search
- internal/controlcenter/embed: OpenAI-compatible /embeddings client
  (batch<=16, 30s timeout, 5xx/network retried once, 4xx not), Embedder
  interface, deterministic offline Stub, pgvector literal formatting
- internal/controlcenter/docs: .doc/**/*.md sync into cc_documents with
  sha256 diffing and vanished-file removal; YAML front matter -> jsonb,
  title fallback, taxonomy from first .doc dir; heading-based chunking
  with paragraph bisection over ~1800 est. tokens; chunk_hash idempotent
  inserts (unchanged chunks keep embeddings); embedding failures degrade
  to NULL vectors without failing the sync; IndexArtifact for
  cc_stage_artifacts; StartPeriodic ticker (CC_DOC_SYNC_SECONDS)
- server: GET /api/v1/docs (taxonomy/q filters), /api/v1/docs/{id},
  /api/v1/docs/search (semantic|keyword|hybrid, ts_rank+ILIKE keyword
  fallback, 0.6/0.4 max-normalized fusion, degraded flag when embedding
  unavailable); async artifact indexing hooked into agent artifacts and
  external-submit handlers
- archtest module-boundaries allowlist updated for new packages
2026-09-17 09:47:29 +00:00

308 lines
8.6 KiB
Go

package server
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"chat-agent-system/internal/controlcenter/embed"
)
const (
searchDefaultLimit = 20
searchMaxLimit = 100
snippetRunes = 300
)
// docListItem is one row of GET /api/v1/docs.
type docListItem struct {
ID string `json:"id"`
Path string `json:"path"`
Title string `json:"title"`
Taxonomy string `json:"taxonomy"`
Frontmatter any `json:"frontmatter,omitempty"`
SyncedAt time.Time `json:"synced_at"`
Chunks int `json:"chunks"`
}
func (s *Server) handleDocList(w http.ResponseWriter, r *http.Request) {
taxonomy := strings.TrimSpace(r.URL.Query().Get("taxonomy"))
q := strings.TrimSpace(r.URL.Query().Get("q"))
like := "%" + escapeLike(q) + "%"
rows, err := s.store.Pool.Query(r.Context(), `
SELECT d.id, d.path, d.title, d.taxonomy, d.synced_at,
(SELECT count(*) FROM cc_doc_chunks c WHERE c.doc_id = d.id) AS chunks
FROM cc_documents d
WHERE ($1 = '' OR d.taxonomy = $1)
AND ($2 = '' OR d.path ILIKE $3 ESCAPE '\' OR d.title ILIKE $3 ESCAPE '\')
ORDER BY d.path`, taxonomy, q, like)
if err != nil {
slog.Error("doc list", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
defer rows.Close()
items := []docListItem{}
for rows.Next() {
var it docListItem
if err := rows.Scan(&it.ID, &it.Path, &it.Title, &it.Taxonomy, &it.SyncedAt, &it.Chunks); err != nil {
slog.Error("doc list scan", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
items = append(items, it)
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "count": len(items)})
}
func (s *Server) handleDocGet(w http.ResponseWriter, r *http.Request) {
var it docListItem
var fm []byte
err := s.store.Pool.QueryRow(r.Context(), `
SELECT d.id, d.path, d.title, d.taxonomy, d.frontmatter, d.synced_at,
(SELECT count(*) FROM cc_doc_chunks c WHERE c.doc_id = d.id) AS chunks
FROM cc_documents d WHERE d.id = $1`, r.PathValue("id")).
Scan(&it.ID, &it.Path, &it.Title, &it.Taxonomy, &fm, &it.SyncedAt, &it.Chunks)
if err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": it.ID, "path": it.Path, "title": it.Title, "taxonomy": it.Taxonomy,
"frontmatter": jsonRaw(fm), "synced_at": it.SyncedAt, "chunks": it.Chunks,
})
}
// searchHit is one scored chunk with its source resolved.
type searchHit struct {
chunkID int64
docID *string
artID *string
heading string
content string
score float64
docPath string
docTitle string
taskSlug string
artStage string
artTitle string
}
const searchSelect = `
SELECT c.id, c.doc_id, c.artifact_id, c.heading, c.content,
COALESCE(d.path,''), COALESCE(d.title,''),
COALESCE(t.slug,''), COALESCE(a.stage,''), COALESCE(a.title,'')`
const searchJoin = `
FROM cc_doc_chunks c
LEFT JOIN cc_documents d ON d.id = c.doc_id
LEFT JOIN cc_stage_artifacts a ON a.id = c.artifact_id
LEFT JOIN cc_tasks t ON t.id = a.task_id`
func (s *Server) semanticHits(ctx context.Context, qvec []float32, limit int) ([]searchHit, error) {
lit := embed.VectorLiteral(qvec)
rows, err := s.store.Pool.Query(ctx, searchSelect+`,
1 - (c.embedding <=> $1::vector) AS score`+searchJoin+`
WHERE c.embedding IS NOT NULL
ORDER BY c.embedding <=> $1::vector
LIMIT $2`, lit, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanHits(rows)
}
func (s *Server) keywordHits(ctx context.Context, q string, limit int) ([]searchHit, error) {
like := "%" + escapeLike(q) + "%"
rows, err := s.store.Pool.Query(ctx, searchSelect+`,
(CASE WHEN c.tsv @@ plainto_tsquery('simple', $1)
THEN ts_rank(c.tsv, plainto_tsquery('simple', $1)) ELSE 0 END
+ CASE WHEN c.content ILIKE $2 ESCAPE '\' THEN 0.5 ELSE 0 END
+ CASE WHEN c.heading ILIKE $2 ESCAPE '\' THEN 0.3 ELSE 0 END) AS score`+searchJoin+`
WHERE c.tsv @@ plainto_tsquery('simple', $1)
OR c.content ILIKE $2 ESCAPE '\'
OR c.heading ILIKE $2 ESCAPE '\'
ORDER BY score DESC
LIMIT $3`, q, like, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanHits(rows)
}
func scanHits(rows interface {
Next() bool
Scan(...any) error
Err() error
}) ([]searchHit, error) {
var out []searchHit
for rows.Next() {
var h searchHit
if err := rows.Scan(&h.chunkID, &h.docID, &h.artID, &h.heading, &h.content,
&h.docPath, &h.docTitle, &h.taskSlug, &h.artStage, &h.artTitle, &h.score); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
func (s *Server) handleDocSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeError(w, http.StatusBadRequest, "q is required")
return
}
mode := r.URL.Query().Get("mode")
if mode == "" {
mode = "hybrid"
}
if mode != "semantic" && mode != "keyword" && mode != "hybrid" {
writeError(w, http.StatusBadRequest, "mode must be semantic|keyword|hybrid")
return
}
limit := searchDefaultLimit
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "limit must be a positive integer")
return
}
limit = min(n, searchMaxLimit)
}
ctx := r.Context()
degraded := false
var vecHits []searchHit
if mode != "keyword" {
if s.emb == nil {
degraded = true
} else if vecs, err := s.emb.Embed(ctx, []string{q}); err != nil || len(vecs) == 0 {
slog.Warn("search: query embedding failed, degrading to keyword", "err", err)
degraded = true
} else if hits, err := s.semanticHits(ctx, vecs[0], limit); err != nil {
slog.Error("search: semantic query", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
} else {
vecHits = hits
}
}
var hits []searchHit
switch {
case mode == "semantic" && !degraded:
hits = vecHits
case mode == "hybrid" && !degraded:
kwHits, err := s.keywordHits(ctx, q, limit)
if err != nil {
slog.Error("search: keyword query", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
hits = fuseHits(vecHits, kwHits, limit)
default: // keyword, or degraded semantic/hybrid
kwHits, err := s.keywordHits(ctx, q, limit)
if err != nil {
slog.Error("search: keyword query", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
hits = kwHits
}
results := make([]map[string]any, 0, len(hits))
for _, h := range hits {
source := map[string]any{"type": "doc", "path": h.docPath, "title": h.docTitle}
if h.artID != nil {
source = map[string]any{"type": "artifact", "task_slug": h.taskSlug, "stage": h.artStage, "title": h.artTitle}
}
results = append(results, map[string]any{
"score": h.score,
"snippet": snippet(h.content),
"heading": h.heading,
"source": source,
"doc_id": h.docID,
"artifact_id": h.artID,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"q": q, "mode": mode, "degraded": degraded, "count": len(results), "results": results,
})
}
// fuseHits merges semantic (weight 0.6) and keyword (weight 0.4) hits after
// per-list max-normalization.
func fuseHits(vecHits, kwHits []searchHit, limit int) []searchHit {
byID := map[int64]*searchHit{}
norm := func(hits []searchHit, weight float64) {
maxScore := 0.0
for _, h := range hits {
if h.score > maxScore {
maxScore = h.score
}
}
if maxScore <= 0 {
maxScore = 1
}
for _, h := range hits {
score := weight * h.score / maxScore
if ex, ok := byID[h.chunkID]; ok {
ex.score += score
} else {
cpy := h
cpy.score = score
byID[h.chunkID] = &cpy
}
}
}
norm(vecHits, 0.6)
norm(kwHits, 0.4)
out := make([]searchHit, 0, len(byID))
for _, h := range byID {
out = append(out, *h)
}
for i := 1; i < len(out); i++ { // insertion sort by score desc, hits are few
for j := i; j > 0 && out[j].score > out[j-1].score; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
if len(out) > limit {
out = out[:limit]
}
return out
}
// snippet returns the first ~snippetRunes runes of content.
func snippet(content string) string {
r := []rune(strings.TrimSpace(content))
if len(r) > snippetRunes {
r = r[:snippetRunes]
}
return string(r)
}
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
// jsonRaw returns frontmatter bytes as an any for the JSON encoder.
func jsonRaw(b []byte) any {
var v any
if len(b) > 0 {
if err := json.Unmarshal(b, &v); err == nil {
return v
}
}
return map[string]any{}
}