@@ -2478,13 +2478,119 @@ def concept_breadth(limit: int = Query(50, ge=1, le=200)):
24782478 con .close ()
24792479
24802480
2481+ def _is_high_signal_graph_chunk (text : str ) -> bool :
2482+ """Suppress glossary/index-style chunks when mining related graph concepts."""
2483+ compact = re .sub (r"\s+" , " " , text or "" ).strip ()
2484+ if len (compact ) < 40 :
2485+ return False
2486+
2487+ title_count = compact .count ("《" )
2488+ latin_token_count = len (re .findall (r"[A-Za-z]{3,}" , compact ))
2489+ number_count = len (re .findall (r"\b\d+\b" , compact ))
2490+
2491+ if title_count >= 3 and (latin_token_count >= 6 or number_count >= 6 ):
2492+ return False
2493+
2494+ if re .search (r"[。!?;]" , compact ):
2495+ return True
2496+
2497+ return bool (re .search (r"[,、::;;]" , compact )) and len (compact ) >= 80 and title_count < 3
2498+
2499+
2500+ GRAPH_GENERIC_TERMS = {
2501+ "描写" , "引用" , "命题" , "修辞" , "叙事" , "抒情" ,
2502+ "阅读" , "思考" , "写作" ,
2503+ }
2504+
2505+
2506+ def _fetch_graph_local_related (con , center_term : str , center_subjects : set [str ], limit : int = 15 ) -> list [dict ]:
2507+ """Mine related graph concepts from the center term's own high-signal chunks."""
2508+ try :
2509+ chunk_rows = con .execute ("""
2510+ SELECT c.subject, c.text
2511+ FROM chunks c JOIN chunks_fts ON chunks_fts.rowid = c.id
2512+ WHERE chunks_fts MATCH ? AND c.source = 'mineru'
2513+ LIMIT 80
2514+ """ , (center_term ,)).fetchall ()
2515+ except Exception :
2516+ return []
2517+
2518+ signal_chunks = [row for row in chunk_rows if _is_high_signal_graph_chunk (row ["text" ] or "" )]
2519+ if not signal_chunks :
2520+ signal_chunks = chunk_rows
2521+ if not signal_chunks :
2522+ return []
2523+
2524+ curated_rows = con .execute ("SELECT term, subject_count, total_count FROM curated_keywords" ).fetchall ()
2525+ concept_rows = con .execute ("SELECT concept, subject FROM concept_map" ).fetchall ()
2526+
2527+ concept_subjects : dict [str , set [str ]] = {}
2528+ for row in concept_rows :
2529+ concept_subjects .setdefault (row ["concept" ], set ()).add (row ["subject" ])
2530+
2531+ candidates = []
2532+ for row in curated_rows :
2533+ term = row ["term" ]
2534+ if term == center_term :
2535+ continue
2536+ if term in GRAPH_GENERIC_TERMS :
2537+ continue
2538+
2539+ term_subjects = concept_subjects .get (term , set ())
2540+ overlap = center_subjects & term_subjects
2541+ if len (overlap ) < 2 :
2542+ continue
2543+
2544+ local_hits = 0
2545+ local_subjects = set ()
2546+ for chunk in signal_chunks :
2547+ chunk_text = chunk ["text" ] or ""
2548+ if term in chunk_text :
2549+ local_hits += 1
2550+ local_subjects .add (chunk ["subject" ])
2551+
2552+ if local_hits == 0 :
2553+ continue
2554+
2555+ subject_count = int (row ["subject_count" ] or len (term_subjects ))
2556+ total_count = int (row ["total_count" ] or 0 )
2557+
2558+ if local_hits < 2 and total_count > 20 :
2559+ continue
2560+
2561+ score = local_hits * 10 + len (local_subjects ) * 4 + len (overlap ) - subject_count
2562+ candidates .append ({
2563+ "term" : term ,
2564+ "shared_subjects" : sorted (overlap ),
2565+ "overlap" : len (overlap ),
2566+ "source" : "local_chunks" ,
2567+ "local_hits" : local_hits ,
2568+ "local_subjects" : sorted (local_subjects ),
2569+ "subject_count" : subject_count ,
2570+ "total_count" : total_count ,
2571+ "score" : score ,
2572+ })
2573+
2574+ candidates .sort (
2575+ key = lambda item : (
2576+ item ["score" ],
2577+ item ["local_hits" ],
2578+ len (item ["local_subjects" ]),
2579+ item ["overlap" ],
2580+ - item ["subject_count" ],
2581+ - item ["total_count" ],
2582+ ),
2583+ reverse = True ,
2584+ )
2585+ return candidates [:limit ]
2586+
2587+
24812588@app .get ("/api/graph/search" )
24822589def graph_search (q : str = Query (..., min_length = 1 )):
24832590 """Return a concept subgraph centered on the search term."""
24842591 con = get_db ()
24852592 try :
24862593 q_clean = q .strip ()
2487- curated = {r ["term" ] for r in con .execute ("SELECT term FROM curated_keywords" ).fetchall ()}
24882594
24892595 # Use FTS for precise subject distribution (not LIKE)
24902596 try :
@@ -2525,26 +2631,13 @@ def graph_search(q: str = Query(..., min_length=1)):
25252631 except Exception :
25262632 pass
25272633
2528- # ── Priority 2: curated concepts with overlap >= 2 ───── ─────
2634+ # ── Priority 2: local co-mentions in high-signal center chunks ─────
25292635 curated_related = []
25302636 seen_terms = {r ["term" ] for r in cluster_related }
2531- for term in curated :
2532- if term == q_clean or term in seen_terms :
2637+ for item in _fetch_graph_local_related ( con , q_clean , center_subjects , limit = 20 ) :
2638+ if item [ " term" ] == q_clean or item [ " term" ] in seen_terms :
25332639 continue
2534- term_subjects_row = con .execute (
2535- "SELECT DISTINCT subject FROM concept_map WHERE concept = ?" , (term ,)
2536- ).fetchall ()
2537- term_subjects = {r ["subject" ] for r in term_subjects_row }
2538- overlap = center_subjects & term_subjects
2539- if len (overlap ) >= 2 : # stricter threshold
2540- curated_related .append ({
2541- "term" : term ,
2542- "shared_subjects" : list (overlap ),
2543- "overlap" : len (overlap ),
2544- "source" : "curated" ,
2545- })
2546-
2547- curated_related .sort (key = lambda x : x ["overlap" ], reverse = True )
2640+ curated_related .append (item )
25482641
25492642 # Merge: clusters first, then curated (max 15 total)
25502643 related = cluster_related + curated_related [:15 - len (cluster_related )]
@@ -2575,6 +2668,8 @@ def graph_search(q: str = Query(..., min_length=1)):
25752668 "subjects" : r ["shared_subjects" ],
25762669 "strength" : r ["overlap" ],
25772670 }
2671+ if r .get ("local_hits" ):
2672+ link_data ["evidence_hits" ] = r ["local_hits" ]
25782673 ai_rel = get_ai_relation (con , q_clean , r ["term" ])
25792674 if ai_rel :
25802675 link_data ["relation" ] = ai_rel ["type" ]
0 commit comments