22package wikiserver
33
44import (
5+ "context"
56 "encoding/json"
67 "errors"
78 "fmt"
@@ -13,6 +14,7 @@ import (
1314 "sort"
1415 "strconv"
1516 "strings"
17+ "time"
1618
1719 "gorm.io/gorm"
1820
@@ -21,6 +23,9 @@ import (
2123 "github.com/tae2089/code-context-graph/internal/model"
2224 nsfs "github.com/tae2089/code-context-graph/internal/namespacefs"
2325 "github.com/tae2089/code-context-graph/internal/ragindex"
26+ "github.com/tae2089/code-context-graph/internal/retrieval"
27+ storesearch "github.com/tae2089/code-context-graph/internal/store/search"
28+ "github.com/tae2089/code-context-graph/internal/wikiindex"
2429 "github.com/tae2089/trace"
2530)
2631
@@ -33,6 +38,7 @@ type Config struct {
3338 RagIndexDir string
3439 NamespaceRoot string
3540 DB * gorm.DB
41+ SearchBackend storesearch.Backend
3642 Logger * slog.Logger
3743}
3844
@@ -43,6 +49,7 @@ type Server struct {
4349 ragIndexDir string
4450 namespaceRoot string
4551 db * gorm.DB
52+ searchBackend storesearch.Backend
4653 logger * slog.Logger
4754}
4855
@@ -76,6 +83,7 @@ func New(cfg Config) (*Server, error) {
7683 ragIndexDir : ragDir ,
7784 namespaceRoot : nsRoot ,
7885 db : cfg .DB ,
86+ searchBackend : cfg .SearchBackend ,
7987 logger : logger ,
8088 }, nil
8189}
@@ -176,13 +184,13 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
176184 writeError (w , http .StatusBadRequest , err .Error (), nil )
177185 return
178186 }
179- idx , err := s .loadIndex ( ns )
187+ root , builtAt , _ , err := s .loadWikiTree ( r . Context (), ns )
180188 if err != nil {
181189 writeError (w , statusForReadErr (err ), "load wiki-index" , err )
182190 return
183191 }
184- root : = ragindex .PruneTree (idx . Root , depth )
185- writeJSON (w , http .StatusOK , map [string ]any {"namespace" : ns , "built_at" : idx . BuiltAt , "root" : root })
192+ root = ragindex .PruneTree (root , depth )
193+ writeJSON (w , http .StatusOK , map [string ]any {"namespace" : ns , "built_at" : builtAt , "root" : root })
186194}
187195
188196// @intent search Wiki tree labels and summaries for the active namespace.
@@ -204,12 +212,12 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
204212 writeError (w , http .StatusBadRequest , err .Error (), nil )
205213 return
206214 }
207- idx , err := s .loadIndex ( ns )
215+ root , _ , _ , err := s .loadWikiTree ( r . Context (), ns )
208216 if err != nil {
209217 writeError (w , statusForReadErr (err ), "load wiki-index" , err )
210218 return
211219 }
212- results := ragindex .Search (idx . Root , query , limit )
220+ results := ragindex .Search (root , query , limit )
213221 if results == nil {
214222 results = []ragindex.SearchResult {}
215223 }
@@ -242,6 +250,15 @@ func (s *Server) handleRetrieve(w http.ResponseWriter, r *http.Request) {
242250 }
243251 idx , err := s .loadRAGIndex (ns )
244252 if err != nil {
253+ if errors .Is (err , fs .ErrNotExist ) && s .db != nil {
254+ response , fallbackErr := s .retrieveFromDB (r .Context (), ns , query , limit , contentLimit )
255+ if fallbackErr != nil {
256+ writeError (w , http .StatusInternalServerError , "retrieve from DB" , fallbackErr )
257+ return
258+ }
259+ writeJSON (w , http .StatusOK , map [string ]any {"namespace" : ns , "results" : response .Results })
260+ return
261+ }
245262 writeError (w , statusForReadErr (err ), "load doc-index" , err )
246263 return
247264 }
@@ -267,6 +284,33 @@ func (s *Server) handleRetrieve(w http.ResponseWriter, r *http.Request) {
267284 writeJSON (w , http .StatusOK , map [string ]any {"namespace" : ns , "results" : results })
268285}
269286
287+ // @intent provide Wiki retrieve results from graph DB data when doc-index.json has not been generated yet.
288+ // @domainRule JSON doc-index remains preferred; this fallback only runs for missing index files.
289+ // @sideEffect queries the graph DB and may read generated Markdown docs.
290+ func (s * Server ) retrieveFromDB (ctx context.Context , namespace , query string , limit , contentLimit int ) (retrieval.Response , error ) {
291+ service := retrieval.Service {DB : s .db , SearchBackend : s .searchBackend }
292+ return service .FromDB (ctx , namespace , query , limit , contentLimit , func (_ context.Context , ns , docPath string , limit int ) (string , bool , error ) {
293+ content , _ , err := s .readDBFallbackDoc (ns , docPath )
294+ if err != nil {
295+ return "" , false , err
296+ }
297+ if len (content ) <= limit {
298+ return content , false , nil
299+ }
300+ return content [:limit ], true , nil
301+ })
302+ }
303+
304+ // @intent read DB fallback document content without crossing from a named namespace into shared/global docs roots.
305+ // @domainRule named namespace retrieve fallback must omit content rather than read another namespace's generated Markdown.
306+ // @sideEffect reads a generated Markdown file from disk when it exists under the namespace root.
307+ func (s * Server ) readDBFallbackDoc (namespace , docPath string ) (string , string , error ) {
308+ if ctxns .Normalize (namespace ) == ctxns .DefaultNamespace {
309+ return s .readDoc (namespace , docPath )
310+ }
311+ return s .readDocUnderRoot (filepath .Join (s .namespaceRoot , namespace ), docPath )
312+ }
313+
270314// @intent return a bounded namespace graph for the browser force-directed graph viewer.
271315func (s * Server ) handleGraph (w http.ResponseWriter , r * http.Request ) {
272316 if ! requireMethod (w , r , http .MethodGet ) {
@@ -369,10 +413,15 @@ func (s *Server) handleDoc(w http.ResponseWriter, r *http.Request) {
369413 writeError (w , http .StatusBadRequest , "path must not be empty" , nil )
370414 return
371415 }
372- content , resolved , err := s .readDoc (ns , docPath )
416+ _ , indexErr := s .loadIndex (ns )
417+ readDoc := s .readDoc
418+ if errors .Is (indexErr , fs .ErrNotExist ) && s .db != nil {
419+ readDoc = s .readDBFallbackDoc
420+ }
421+ content , resolved , err := readDoc (ns , docPath )
373422 if err != nil {
374- if idx , indexErr := s .loadIndex ( ns ); indexErr == nil {
375- if node := findDocPath (idx . Root , docPath ); node != nil {
423+ if root , _ , _ , indexErr := s .loadWikiTree ( r . Context (), ns ); indexErr == nil {
424+ if node := findDocPath (root , docPath ); node != nil {
376425 writeJSON (w , http .StatusOK , map [string ]any {
377426 "namespace" : ns ,
378427 "path" : docPath ,
@@ -414,10 +463,10 @@ func (s *Server) handleRef(w http.ResponseWriter, r *http.Request) {
414463 writeError (w , http .StatusBadRequest , err .Error (), nil )
415464 return
416465 }
417- idx , indexErr := s .loadIndex ( ref .Namespace )
466+ root , _ , _ , indexErr := s .loadWikiTree ( r . Context (), ref .Namespace )
418467 var treeNode * ragindex.TreeNode
419468 if indexErr == nil {
420- treeNode = findRefTreeNode (idx . Root , ref )
469+ treeNode = findRefTreeNode (root , ref )
421470 }
422471 graphNode , graphErr := s .findRefGraphNode (r , ref )
423472 if treeNode == nil && graphNode == nil {
@@ -458,11 +507,15 @@ func (s *Server) handleContext(w http.ResponseWriter, r *http.Request) {
458507 writeError (w , http .StatusBadRequest , "paths must contain <= 20 items" , nil )
459508 return
460509 }
461- idx , err := s .loadIndex ( ns )
510+ root , _ , fromDB , err := s .loadWikiTree ( r . Context (), ns )
462511 if err != nil {
463512 writeError (w , statusForReadErr (err ), "load wiki-index" , err )
464513 return
465514 }
515+ readDoc := s .readDoc
516+ if fromDB {
517+ readDoc = s .readDBFallbackDoc
518+ }
466519 sections := make ([]string , 0 , len (req .Paths ))
467520 items := make ([]contextItem , 0 , len (req .Paths ))
468521 for _ , rawPath := range req .Paths {
@@ -471,14 +524,14 @@ func (s *Server) handleContext(w http.ResponseWriter, r *http.Request) {
471524 continue
472525 }
473526 item := contextItem {Path : docPath }
474- if content , _ , err := s . readDoc (ns , docPath ); err == nil {
527+ if content , _ , err := readDoc (ns , docPath ); err == nil {
475528 item .Found = true
476529 item .Markdown = fmt .Sprintf ("## %s\n \n %s" , docPath , strings .TrimSpace (content ))
477530 sections = append (sections , item .Markdown )
478- } else if node := findDocPath (idx . Root , docPath ); node != nil {
531+ } else if node := findDocPath (root , docPath ); node != nil {
479532 item .Found = true
480533 item .Label = node .Label
481- item .Markdown = fmt .Sprintf ("## %s\n \n %s" , node .Label , strings .TrimSpace (node . Summary ))
534+ item .Markdown = fmt .Sprintf ("## %s\n \n %s" , node .Label , strings .TrimSpace (nodeMarkdown ( node ) ))
482535 sections = append (sections , item .Markdown )
483536 } else {
484537 item .Error = "not found"
@@ -625,6 +678,23 @@ func (s *Server) loadIndex(namespace string) (*ragindex.Index, error) {
625678 return ragindex .LoadIndex (s .indexPath (namespace ))
626679}
627680
681+ // @intent load a Wiki tree from JSON or DB fallback while preserving built_at response metadata.
682+ // @domainRule generated wiki-index.json remains authoritative; DB fallback only runs when the file is absent and DB is configured.
683+ func (s * Server ) loadWikiTree (ctx context.Context , namespace string ) (* ragindex.TreeNode , time.Time , bool , error ) {
684+ idx , err := s .loadIndex (namespace )
685+ if err == nil {
686+ return idx .Root , idx .BuiltAt , false , nil
687+ }
688+ if ! errors .Is (err , fs .ErrNotExist ) || s .db == nil {
689+ return nil , time.Time {}, false , err
690+ }
691+ root , _ , _ , buildErr := (& wikiindex.Builder {DB : s .db , OutDir : "docs" , Namespace : namespace }).BuildTree (ctx )
692+ if buildErr != nil {
693+ return nil , time.Time {}, false , buildErr
694+ }
695+ return root , time .Now ().UTC (), true , nil
696+ }
697+
628698// @intent load the namespace-specific doc-index used by PageIndex retrieval.
629699func (s * Server ) loadRAGIndex (namespace string ) (* ragindex.Index , error ) {
630700 if ctxns .Normalize (namespace ) == ctxns .DefaultNamespace {
@@ -672,6 +742,24 @@ func (s *Server) readDoc(namespace, docPath string) (string, string, error) {
672742 if err != nil {
673743 return "" , "" , err
674744 }
745+ return readDocFile (resolved )
746+ }
747+
748+ // @intent read a generated doc path from one explicit root with the standard Wiki size limit.
749+ func (s * Server ) readDocUnderRoot (root , docPath string ) (string , string , error ) {
750+ clean := filepath .Clean (docPath )
751+ if clean == "." || clean == ".." || filepath .IsAbs (clean ) || strings .HasPrefix (clean , ".." + string (os .PathSeparator )) {
752+ return "" , "" , fmt .Errorf ("invalid path: path traversal not allowed" )
753+ }
754+ resolved , err := safePath (root , clean )
755+ if err != nil {
756+ return "" , "" , err
757+ }
758+ return readDocFile (resolved )
759+ }
760+
761+ // @intent enforce generated doc size limits and read the resolved Markdown file.
762+ func readDocFile (resolved string ) (string , string , error ) {
675763 info , err := os .Stat (resolved )
676764 if err != nil {
677765 return "" , "" , err
@@ -850,6 +938,9 @@ func refTargetFromMatches(treeNode *ragindex.TreeNode, graphNode *graphNode) ref
850938 }
851939 target .GraphNodeID = graphNode .ID
852940 }
941+ if target .DocPath == "" && target .Details != nil && strings .TrimSpace (target .Details .FilePath ) != "" {
942+ target .DocPath = docPathForSource (target .Details .FilePath )
943+ }
853944 return target
854945}
855946
@@ -894,7 +985,11 @@ func nodeMarkdown(node *ragindex.TreeNode) string {
894985 if len (node .Children ) > 0 {
895986 var children []string
896987 for _ , child := range node .Children {
897- children = append (children , "- " + child .Label )
988+ line := "- " + child .Label
989+ if strings .TrimSpace (child .Summary ) != "" {
990+ line += ": " + strings .TrimSpace (child .Summary )
991+ }
992+ children = append (children , line )
898993 }
899994 parts = append (parts , strings .Join (children , "\n " ))
900995 }
@@ -938,7 +1033,7 @@ func graphEdgeKindsParam(r *http.Request) ([]model.EdgeKind, error) {
9381033 }
9391034 var out []model.EdgeKind
9401035 seen := map [model.EdgeKind ]struct {}{}
941- for _ , item := range strings .Split (raw , "," ) {
1036+ for item := range strings .SplitSeq (raw , "," ) {
9421037 kind := model .EdgeKind (strings .TrimSpace (item ))
9431038 if kind == "" {
9441039 continue
0 commit comments