graphify-vault 0.6.0__tar.gz → 0.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/PKG-INFO +1 -1
  2. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/__main__.py +59 -35
  3. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/build_cmd.py +18 -0
  4. graphify_vault-0.6.2/graphify/feedback.py +343 -0
  5. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/query_log.py +8 -0
  6. graphify_vault-0.6.2/graphify/retrieval_metrics.py +286 -0
  7. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/serve.py +1580 -401
  8. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/simplify.py +46 -0
  9. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/PKG-INFO +1 -1
  10. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/SOURCES.txt +6 -0
  11. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/pyproject.toml +3 -1
  12. graphify_vault-0.6.2/tests/test_feedback.py +351 -0
  13. graphify_vault-0.6.2/tests/test_query_cache.py +486 -0
  14. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_query_determinism.py +0 -1
  15. graphify_vault-0.6.2/tests/test_query_snippet.py +88 -0
  16. graphify_vault-0.6.2/tests/test_query_variants.py +57 -0
  17. graphify_vault-0.6.2/tests/test_rerank.py +703 -0
  18. graphify_vault-0.6.2/tests/test_retrieval_metrics.py +158 -0
  19. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_serve.py +24 -12
  20. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_simplify.py +84 -0
  21. graphify_vault-0.6.2/tests/test_source_context.py +89 -0
  22. graphify_vault-0.6.0/graphify/feedback.py +0 -149
  23. graphify_vault-0.6.0/tests/test_feedback.py +0 -268
  24. graphify_vault-0.6.0/tests/test_rerank.py +0 -259
  25. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/LICENSE +0 -0
  26. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/README.md +0 -0
  27. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/__init__.py +0 -0
  28. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/_minhash.py +0 -0
  29. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/affected.py +0 -0
  30. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/analyze.py +0 -0
  31. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/audit.py +0 -0
  32. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/benchmark.py +0 -0
  33. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/bin_cache.py +0 -0
  34. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/build.py +0 -0
  35. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/cache.py +0 -0
  36. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/cluster.py +0 -0
  37. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/compat.py +0 -0
  38. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/compile.py +0 -0
  39. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/dedup.py +0 -0
  40. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/detect.py +0 -0
  41. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/doctor.py +0 -0
  42. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/export.py +0 -0
  43. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/extract.py +0 -0
  44. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/hooks.py +0 -0
  45. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/ingest.py +0 -0
  46. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/llm.py +0 -0
  47. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/manifest.py +0 -0
  48. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/mcp_cmd.py +0 -0
  49. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/multigraph_compat.py +0 -0
  50. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/report.py +0 -0
  51. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/security.py +0 -0
  52. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/skill.md +0 -0
  53. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/transcribe.py +0 -0
  54. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/validate.py +0 -0
  55. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/watch.py +0 -0
  56. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/wiki.py +0 -0
  57. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/dependency_links.txt +0 -0
  58. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/entry_points.txt +0 -0
  59. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/requires.txt +0 -0
  60. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/top_level.txt +0 -0
  61. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/setup.cfg +0 -0
  62. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_affected.py +0 -0
  63. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_analyze.py +0 -0
  64. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_audit.py +0 -0
  65. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_benchmark.py +0 -0
  66. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_bin_cache.py +0 -0
  67. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_build.py +0 -0
  68. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_build_multigraph.py +0 -0
  69. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_cache.py +0 -0
  70. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_claude_md.py +0 -0
  71. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_cluster.py +0 -0
  72. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_cluster_multigraph.py +0 -0
  73. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_compile.py +0 -0
  74. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_confidence.py +0 -0
  75. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_dedup.py +0 -0
  76. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_detect.py +0 -0
  77. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_export.py +0 -0
  78. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_export_multigraph.py +0 -0
  79. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_extract.py +0 -0
  80. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_extract_headingless.py +0 -0
  81. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_graph_build_perf.py +0 -0
  82. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_heading_cluster.py +0 -0
  83. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_heading_compat.py +0 -0
  84. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_heading_extract.py +0 -0
  85. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_heading_query.py +0 -0
  86. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_hooks.py +0 -0
  87. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_hypergraph.py +0 -0
  88. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_ingest.py +0 -0
  89. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_install.py +0 -0
  90. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_languages.py +0 -0
  91. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_llm.py +0 -0
  92. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_main_multigraph.py +0 -0
  93. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_mcp_cmd.py +0 -0
  94. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_multigraph_compat.py +0 -0
  95. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_multigraph_edge_compat.py +0 -0
  96. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_multilang.py +0 -0
  97. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_pipeline.py +0 -0
  98. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_query_log.py +0 -0
  99. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_rationale.py +0 -0
  100. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_report.py +0 -0
  101. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_security.py +0 -0
  102. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_semantic_similarity.py +0 -0
  103. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_serve_multigraph.py +0 -0
  104. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_serve_multigraph_load.py +0 -0
  105. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_sort_by.py +0 -0
  106. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_spatial_craft_build.py +0 -0
  107. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_spatialsdk_query_regression.py +0 -0
  108. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_transcribe.py +0 -0
  109. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_validate.py +0 -0
  110. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_watch.py +0 -0
  111. {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_wiki.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphify_vault
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Fork of github.com/safishamsi/graphify with GRAPHIFY_WORKSPACE path resolution for distributed graph artifacts, same-label node merging, incremental build fixes, and local knowledge vault capabilities
5
5
  License: MIT License
6
6
 
@@ -251,7 +251,7 @@ def _agent_mcp_rule(mcp_name: str, platform_name: str) -> str:
251
251
  + f"This project uses the `{mcp_name}` MCP server as an external knowledge source.\n\n"
252
252
  + "Rules:\n"
253
253
  + "- For relevant architecture, API usage, implementation, or troubleshooting questions, query the knowledge graph before answering.\n"
254
- + "- Start with `query_graph` using `depth=3` and `token_budget=3000`.\n"
254
+ + "- Start with `query_graph` using `depth=3`.\n"
255
255
  + "- Treat graph results as retrieval context, not as the final answer.\n"
256
256
  + "- Read the referenced source files and line ranges when detailed evidence is needed.\n"
257
257
  + "- If retrieval is incomplete, retry with alternate terminology instead of guessing.\n"
@@ -1292,7 +1292,7 @@ def main() -> None:
1292
1292
  print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
1293
1293
  print(' query "<question>" BFS traversal of graph.json for a question')
1294
1294
  print(" --dfs use depth-first instead of breadth-first")
1295
- print(" --budget N cap output at N tokens (default 2000)")
1295
+ print(" --max-degree N filter hub nodes (default 0 = unlimited)")
1296
1296
  print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
1297
1297
  print(" save-result save a Q&A result to graphify-out/memory/ for graph feedback loop")
1298
1298
  print(" --question Q the question asked")
@@ -1487,7 +1487,7 @@ def main() -> None:
1487
1487
  elif cmd == "query":
1488
1488
  if len(sys.argv) < 3:
1489
1489
  print(
1490
- 'Usage: graphify query "<question>" [--dfs] [--json] [--budget N] [--max-degree N] [--sort-by degree|relevance] [--query-variant TEXT ...] [--leaf-depth N] [--graph path]',
1490
+ 'Usage: graphify query "<question>" [--dfs] [--json] [--max-degree N] [--sort-by degree|relevance] [--query-variant TEXT ...] [--leaf-depth N] [--graph path]',
1491
1491
  file=sys.stderr,
1492
1492
  )
1493
1493
  sys.exit(1)
@@ -1505,21 +1505,7 @@ def main() -> None:
1505
1505
  args = sys.argv[3:]
1506
1506
  i = 0
1507
1507
  while i < len(args):
1508
- if args[i] == "--budget" and i + 1 < len(args):
1509
- try:
1510
- budget = int(args[i + 1])
1511
- except ValueError:
1512
- print("error: --budget must be an integer", file=sys.stderr)
1513
- sys.exit(1)
1514
- i += 2
1515
- elif args[i].startswith("--budget="):
1516
- try:
1517
- budget = int(args[i].split("=", 1)[1])
1518
- except ValueError:
1519
- print("error: --budget must be an integer", file=sys.stderr)
1520
- sys.exit(1)
1521
- i += 1
1522
- elif args[i] == "--max-degree" and i + 1 < len(args):
1508
+ if args[i] == "--max-degree" and i + 1 < len(args):
1523
1509
  try:
1524
1510
  max_degree = int(args[i + 1])
1525
1511
  except ValueError:
@@ -2089,7 +2075,14 @@ def main() -> None:
2089
2075
  print(f" {result.total_files} files analyzed, {flagged} flagged, avg health={result.avg_health:.2f}")
2090
2076
 
2091
2077
  elif cmd == "feedback":
2092
- from graphify.feedback import analyze_daily_log, clear_feedback, load_feedback
2078
+ from graphify.feedback import (
2079
+ analyze_daily_log,
2080
+ clear_feedback,
2081
+ disable_ranking,
2082
+ enable_ranking,
2083
+ load_feedback,
2084
+ ranking_enabled,
2085
+ )
2093
2086
 
2094
2087
  graph_dir = Path("graphify-out")
2095
2088
  node_id = None
@@ -2105,40 +2098,68 @@ def main() -> None:
2105
2098
  elif a == "--workspace" and i + 1 < len(args):
2106
2099
  graph_dir = Path(args[i + 1]) / "graphify-out"
2107
2100
  i += 2
2108
- elif a == "--node" and i + 1 < len(args):
2101
+ elif a in ("--node", "--file") and i + 1 < len(args):
2109
2102
  node_id = args[i + 1]
2110
2103
  i += 2
2111
- elif a == "--list":
2104
+ elif a in ("--list", "list"):
2112
2105
  action = "list"
2113
2106
  i += 1
2114
- elif a == "--clear":
2107
+ elif a in ("--clear", "clear"):
2115
2108
  action = "clear"
2116
2109
  i += 1
2117
- elif a == "--analyze":
2110
+ elif a in ("--analyze", "analyze"):
2118
2111
  action = "analyze"
2119
2112
  i += 1
2113
+ elif a in ("--enable", "enable"):
2114
+ action = "enable"
2115
+ i += 1
2116
+ elif a in ("--disable", "disable"):
2117
+ action = "disable"
2118
+ i += 1
2119
+ elif a in ("--status", "status"):
2120
+ action = "status"
2121
+ i += 1
2120
2122
  else:
2121
2123
  i += 1
2122
2124
 
2123
- if action == "list":
2125
+ if action == "enable":
2126
+ enable_ranking(graph_dir)
2127
+ print(f"Ranking feedback enabled: {graph_dir}")
2128
+ elif action == "disable":
2129
+ disable_ranking(graph_dir)
2130
+ print(f"Ranking feedback disabled: {graph_dir}")
2131
+ elif action == "status":
2132
+ enabled = ranking_enabled(graph_dir)
2133
+ fb = load_feedback(graph_dir)
2134
+ fb_path = graph_dir / "feedback.json"
2135
+ print(f"Ranking feedback: {'enabled' if enabled else 'disabled'}")
2136
+ print(f"Config: {graph_dir / '.feedback_ranking'}")
2137
+ print(f"Feedback file: {fb_path} ({len(fb)} files)")
2138
+ print(f"Env override: {os.environ.get('GRAPHIFY_FEEDBACK_RANKING') or 'not set'}")
2139
+ elif action == "list":
2124
2140
  fb = load_feedback(graph_dir)
2125
2141
  if not fb:
2126
2142
  print("No feedback recorded.")
2127
2143
  else:
2128
2144
  for nid, score in sorted(fb.items(), key=lambda x: x[1]):
2129
- print(f" {nid}: {score:+.1f}")
2130
- print(f" ({len(fb)} nodes with feedback)")
2145
+ print(f" {nid}: {score:+.2f}")
2146
+ print(f" ({len(fb)} files with feedback)")
2131
2147
  elif action == "clear":
2132
2148
  clear_feedback(graph_dir)
2133
2149
  print("Feedback cleared.")
2134
2150
  elif action == "analyze":
2151
+ from datetime import datetime
2152
+
2135
2153
  from graphify.query_log import _LOG_DIR
2136
2154
 
2155
+ today = f"query_{datetime.now().strftime('%Y-%m-%d')}"
2137
2156
  total = 0
2138
2157
  for log_file in sorted(_LOG_DIR.glob("query_*.jsonl")):
2139
- n = analyze_daily_log(log_file, graph_dir)
2158
+ # Today's log is still growing: analyze it now but do not
2159
+ # write the marker, so tomorrow's run sees the complete day.
2160
+ n = analyze_daily_log(log_file, graph_dir, write_marker=log_file.stem != today)
2140
2161
  if n > 0:
2141
- print(f" {log_file.name}: updated {n} node(s)")
2162
+ print(f" {log_file.name}: updated {n} file(s)")
2142
2163
  total += n
2143
2164
  if total == 0:
2144
2165
  print("No feedback updates from query logs.")
@@ -2146,15 +2167,15 @@ def main() -> None:
2146
2167
  fb = load_feedback(graph_dir)
2147
2168
  neg = {k: v for k, v in fb.items() if v < 0}
2148
2169
  pos = {k: v for k, v in fb.items() if v > 0}
2149
- print(f" Total: {total} node(s) updated ({len(neg)} noise, {len(pos)} core)")
2170
+ print(f" Total: {total} file(s) updated ({len(neg)} noise, {len(pos)} core)")
2150
2171
  if neg:
2151
2172
  top_neg = sorted(neg.items(), key=lambda x: x[1])[:5]
2152
- print(" Top noise nodes:")
2173
+ print(" Top noise files:")
2153
2174
  for nid, score in top_neg:
2154
2175
  print(f" {nid}: {score:+.2f}")
2155
2176
  if pos:
2156
2177
  top_pos = sorted(pos.items(), key=lambda x: -x[1])[:5]
2157
- print(" Top core nodes:")
2178
+ print(" Top core files:")
2158
2179
  for nid, score in top_pos:
2159
2180
  print(f" {nid}: {score:+.2f}")
2160
2181
  elif node_id:
@@ -2162,13 +2183,16 @@ def main() -> None:
2162
2183
  score = fb.get(node_id, 0.0)
2163
2184
  from graphify.feedback import feedback_multiplier
2164
2185
 
2165
- print(f" {node_id}: score={score:+.1f} multiplier={feedback_multiplier(score):.2f}")
2186
+ print(f" {node_id}: score={score:+.2f} multiplier={feedback_multiplier(score):.2f}")
2166
2187
  else:
2167
- print("Usage: graphify feedback [--list | --clear | --analyze | --node <id>]")
2168
- print(" --list list all nodes with feedback")
2188
+ print("Usage: graphify feedback [status | enable | disable | --list | --clear | --analyze | --file <path>]")
2189
+ print(" status show whether feedback affects ranking")
2190
+ print(" enable let feedback adjust ranking (off by default)")
2191
+ print(" disable keep collecting feedback without affecting ranking")
2192
+ print(" --list list all files with feedback")
2169
2193
  print(" --clear clear all feedback")
2170
2194
  print(" --analyze analyze query logs and update feedback")
2171
- print(" --node <id> show feedback for a specific node")
2195
+ print(" --file <path> show feedback for a specific source file")
2172
2196
  print(" --graph <path> graphify-out directory (default: ./graphify-out)")
2173
2197
  print(" --workspace <path> workspace root (uses <path>/graphify-out)")
2174
2198
 
@@ -627,6 +627,15 @@ def run_build(args: list[str] | None = None) -> None:
627
627
 
628
628
  communities = cluster(G)
629
629
 
630
+ if not opts.no_simplify:
631
+ from graphify.simplify import post_cluster_cleanup
632
+
633
+ post_cluster_cleanup(G)
634
+ # Remove stale node references from communities after pruning
635
+ node_set = set(G.nodes())
636
+ communities = {cid: [n for n in members if n in node_set] for cid, members in communities.items()}
637
+ communities = {cid: members for cid, members in communities.items() if members}
638
+
630
639
  cohesion = score_all(G, communities)
631
640
  gods = god_nodes(G)
632
641
  surprises = surprising_connections(G, communities)
@@ -863,6 +872,15 @@ def run_build(args: list[str] | None = None) -> None:
863
872
 
864
873
  communities = cluster(G)
865
874
 
875
+ if not opts.no_simplify:
876
+ from graphify.simplify import post_cluster_cleanup
877
+
878
+ post_cluster_cleanup(G)
879
+ # Remove stale node references from communities after pruning
880
+ node_set = set(G.nodes())
881
+ communities = {cid: [n for n in members if n in node_set] for cid, members in communities.items()}
882
+ communities = {cid: members for cid, members in communities.items() if members}
883
+
866
884
  cohesion = score_all(G, communities)
867
885
  gods = god_nodes(G)
868
886
  surprises = surprising_connections(G, communities)
@@ -0,0 +1,343 @@
1
+ """File-level reputation feedback — automatic quality signal from query logs.
2
+
3
+ Design:
4
+
5
+ * **Unit = source file.** The query output and the rerank funnel are
6
+ file-level (one merged entry per document), so node-level reputation was
7
+ measuring a unit that no longer exists in the output.
8
+ * **Signal = rank-weighted exposure.** A file returned at rank ``r``
9
+ contributes ``3**-(r-1)``: consistently appearing near the top means the
10
+ file answers questions, only appearing in the tail means it is noise.
11
+ * **Strength = ±20%.** Ranking already selects by concept coverage, so
12
+ feedback breaks ties instead of overriding relevance.
13
+ * **Time = 30-day half-life.** Old behaviour fades instead of accumulating
14
+ forever; the decay is applied lazily from ``_meta.updated``.
15
+
16
+ Compatibility is by **field**, not by a version number: the file-level data
17
+ lives in ``files`` and the legacy node-level ``scores`` field is ignored. A
18
+ reader works with both old and new files, and malformed data degrades to
19
+ "no feedback" instead of raising. Writers only emit ``files``; an older
20
+ process still running would overwrite the file with its own ``scores``, so
21
+ upgrade every process before enabling this format.
22
+
23
+ The previous signal (``seed / recall`` ratio per node) is intentionally not
24
+ migrated: seeds are lexical matches while the ranking now selects by
25
+ coverage, so a correct answer source is often not a seed.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import sys
33
+ import threading
34
+ from collections import defaultdict
35
+ from datetime import date, datetime
36
+ from pathlib import Path
37
+
38
+ _fb_cache: dict = {}
39
+
40
+ _SCHEMA = "file-reputation"
41
+ _RANKING_CONFIG = ".feedback_ranking"
42
+ _HALF_LIFE_DAYS = 30.0
43
+ _RANK_DECAY = 1.0 / 3.0
44
+ _MIN_SAMPLE = 3 # appearances before a file earns a score
45
+ _MIN_QUESTIONS = 2 # distinct questions before a file earns a score
46
+ _BLEND = 0.3 # weight of the new estimate vs the stored score
47
+ _MULTIPLIER_RANGE = 0.2 # score ±1 maps to multiplier 1 ± 0.2
48
+
49
+
50
+ def canonical_source_key(source_file: str, workspace: Path | str) -> str:
51
+ """Return a workspace-relative POSIX key for feedback storage/lookup.
52
+
53
+ ``_resolve_source_file`` returns absolute paths in some processes and
54
+ relative paths in others, so the same document used to be stored under
55
+ two keys and each half saw only part of the evidence. Normalising at
56
+ write, load and lookup keeps one key per document.
57
+ """
58
+ if not source_file:
59
+ return ""
60
+ path = Path(source_file)
61
+ if not path.is_absolute():
62
+ return path.as_posix()
63
+ try:
64
+ return path.resolve().relative_to(Path(workspace).resolve()).as_posix()
65
+ except (ValueError, OSError):
66
+ return path.as_posix()
67
+
68
+
69
+ def load_feedback(graph_dir: Path) -> dict[str, float]:
70
+ """Load file reputation, applying the 30-day half-life lazily.
71
+
72
+ Compatibility is by field, not by version number: the file-level data
73
+ lives in ``files``; the legacy node-level ``scores`` field is ignored.
74
+ A reader therefore works with both old and new files, and a missing or
75
+ malformed field degrades to "no feedback" instead of an error.
76
+
77
+ The cache is keyed by the file's mtime so a CLI ``feedback --analyze``
78
+ becomes visible to a running MCP server without waiting for the next day.
79
+ """
80
+ fb_path = graph_dir / "feedback.json"
81
+ key = str(graph_dir)
82
+ today = datetime.now().strftime("%Y-%m-%d")
83
+ try:
84
+ mtime = fb_path.stat().st_mtime
85
+ except OSError:
86
+ mtime = None
87
+
88
+ cached = _fb_cache.get(key)
89
+ if cached and cached["date"] == today and cached["mtime"] == mtime:
90
+ return cached["data"]
91
+
92
+ scores: dict[str, float] = {}
93
+ if mtime is not None:
94
+ try:
95
+ data = json.loads(fb_path.read_text(encoding="utf-8"))
96
+ except (json.JSONDecodeError, OSError):
97
+ data = {}
98
+ files = data.get("files") if isinstance(data, dict) else None
99
+ if isinstance(files, dict):
100
+ grouped: dict[str, list[float]] = {}
101
+ for name, value in files.items():
102
+ try:
103
+ score = float(value)
104
+ except (TypeError, ValueError):
105
+ continue
106
+ canonical = canonical_source_key(str(name), graph_dir.parent)
107
+ grouped.setdefault(canonical, []).append(score)
108
+ # Absolute and relative keys can describe the same document;
109
+ # average their evidence instead of letting one shadow the other.
110
+ scores = {name: sum(values) / len(values) for name, values in grouped.items() if name}
111
+ updated = (data.get("_meta") or {}).get("updated")
112
+ if updated:
113
+ try:
114
+ days = (date.fromisoformat(today) - date.fromisoformat(updated)).days
115
+ except (TypeError, ValueError):
116
+ days = 0
117
+ if days > 0:
118
+ factor = 0.5 ** (days / _HALF_LIFE_DAYS)
119
+ scores = {k: v * factor for k, v in scores.items() if abs(v * factor) >= 0.01}
120
+
121
+ _fb_cache[key] = {"date": today, "mtime": mtime, "data": scores}
122
+ return scores
123
+
124
+
125
+ def save_feedback(graph_dir: Path, feedback: dict[str, float]) -> None:
126
+ """Write the file-level reputation atomically.
127
+
128
+ Only ``files`` is written; the legacy ``scores`` field is dropped. An
129
+ older process still running would overwrite the whole file with its own
130
+ ``scores`` payload, so upgrade every process before enabling v2.
131
+ """
132
+ fb_path = graph_dir / "feedback.json"
133
+ graph_dir.mkdir(parents=True, exist_ok=True)
134
+ data = {
135
+ "_meta": {
136
+ "schema": _SCHEMA,
137
+ "updated": datetime.now().strftime("%Y-%m-%d"),
138
+ },
139
+ "files": feedback,
140
+ }
141
+ temp_path = fb_path.with_suffix(".json.tmp")
142
+ temp_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
143
+ os.replace(temp_path, fb_path)
144
+ _fb_cache.pop(str(graph_dir), None)
145
+
146
+
147
+ def ranking_enabled(graph_dir: Path | None = None) -> bool:
148
+ """Whether reputation may influence ranking.
149
+
150
+ Default is OFF. Rank-exposure reputation is derived from the ranking it
151
+ then adjusts, so it can only reinforce the current order; in practice it
152
+ also demotes broad overview documents that are frequently returned but
153
+ rarely first. Collection and analysis keep running.
154
+
155
+ Resolution order:
156
+ 1. ``GRAPHIFY_FEEDBACK_RANKING`` env (1/0, for temporary experiments)
157
+ 2. ``<graphify-out>/.feedback_ranking`` (written by ``gv feedback enable``)
158
+ 3. disabled
159
+ """
160
+ env = os.environ.get("GRAPHIFY_FEEDBACK_RANKING")
161
+ if env is not None:
162
+ return env.strip().lower() in ("1", "true", "yes", "on")
163
+ if graph_dir is not None:
164
+ config = graph_dir / _RANKING_CONFIG
165
+ try:
166
+ return config.read_text(encoding="utf-8").strip().lower() in ("1", "true", "yes", "on")
167
+ except OSError:
168
+ return False
169
+ return False
170
+
171
+
172
+ def enable_ranking(graph_dir: Path) -> None:
173
+ graph_dir.mkdir(parents=True, exist_ok=True)
174
+ (graph_dir / _RANKING_CONFIG).write_text("1", encoding="utf-8")
175
+
176
+
177
+ def disable_ranking(graph_dir: Path) -> None:
178
+ graph_dir.mkdir(parents=True, exist_ok=True)
179
+ (graph_dir / _RANKING_CONFIG).write_text("0", encoding="utf-8")
180
+
181
+
182
+ def feedback_multiplier(score: float) -> float:
183
+ """Map a reputation score in [-1, 1] to a multiplier in [0.8, 1.2]."""
184
+ lower = 1.0 - _MULTIPLIER_RANGE
185
+ upper = 1.0 + _MULTIPLIER_RANGE
186
+ return max(lower, min(upper, 1.0 + score * _MULTIPLIER_RANGE))
187
+
188
+
189
+ def clear_feedback(graph_dir: Path) -> None:
190
+ fb_path = graph_dir / "feedback.json"
191
+ if fb_path.exists():
192
+ fb_path.unlink()
193
+ _fb_cache.pop(str(graph_dir), None)
194
+ # ``.feedback_analyzed`` (no date suffix) is the legacy marker name.
195
+ for marker in graph_dir.glob(".feedback_analyzed*"):
196
+ marker.unlink()
197
+
198
+
199
+ def _rank_weight(rank: int) -> float:
200
+ """Weight of a result at 1-based ``rank``: 1, 1/3, 1/9, ..."""
201
+ return _RANK_DECAY ** (rank - 1)
202
+
203
+
204
+ def _result_files(record: dict) -> list[str]:
205
+ """Ordered source files for one log record.
206
+
207
+ New logs carry a structured ``results`` list. Older logs only have the
208
+ rendered text, which cannot be parsed reliably, so they contribute no
209
+ ranking signal.
210
+ """
211
+ files: list[str] = []
212
+ for item in record.get("results") or []:
213
+ if isinstance(item, dict):
214
+ source = str(item.get("source_file") or "")
215
+ if source:
216
+ files.append(source)
217
+ return files
218
+
219
+
220
+ def analyze_daily_log(log_path: Path, graph_dir: Path, *, write_marker: bool = True) -> int:
221
+ """Update file reputation from one query log.
222
+
223
+ Returns the number of files whose stored score changed. A marker file
224
+ makes the analysis idempotent per log. ``write_marker=False`` is used
225
+ for today's still-growing log: it can be analyzed now and again later.
226
+ """
227
+ if not log_path.exists():
228
+ return 0
229
+
230
+ marker = graph_dir / f".feedback_analyzed_{log_path.stem}"
231
+ if marker.exists():
232
+ return 0
233
+
234
+ workspace_filter = os.path.normpath(str(graph_dir.parent))
235
+ weighted: dict[str, float] = defaultdict(float)
236
+ appearances: dict[str, int] = defaultdict(int)
237
+ questions: dict[str, set[str]] = defaultdict(set)
238
+ total_weight = 0.0
239
+ total_results = 0
240
+
241
+ with log_path.open("r", encoding="utf-8") as handle:
242
+ for line in handle:
243
+ line = line.strip()
244
+ if not line:
245
+ continue
246
+ try:
247
+ record = json.loads(line)
248
+ except json.JSONDecodeError:
249
+ continue
250
+ # An empty workspace cannot be attributed to a graph; skipping
251
+ # is safer than mixing several workspaces into one reputation.
252
+ # Normalise both sides: GRAPHIFY_WORKSPACE is often exported with
253
+ # a trailing slash, which an exact string compare would reject.
254
+ record_workspace = os.path.normpath(str(record.get("workspace", ""))) if record.get("workspace") else ""
255
+ if not record_workspace or record_workspace != workspace_filter:
256
+ continue
257
+ question = str(record.get("question", ""))
258
+ for rank, source in enumerate(_result_files(record)[:20], 1):
259
+ key = canonical_source_key(source, graph_dir.parent)
260
+ if not key:
261
+ continue
262
+ weight = _rank_weight(rank)
263
+ weighted[key] += weight
264
+ appearances[key] += 1
265
+ questions[key].add(question)
266
+ total_weight += weight
267
+ total_results += 1
268
+
269
+ if total_results == 0:
270
+ # Logs written before structured results existed can never contribute;
271
+ # mark them so every future query does not rescan the whole history.
272
+ if write_marker:
273
+ try:
274
+ marker.write_text("1", encoding="utf-8")
275
+ except OSError:
276
+ pass
277
+ return 0
278
+
279
+ mean_weight = total_weight / total_results
280
+ feedback = load_feedback(graph_dir)
281
+ updated = 0
282
+
283
+ for source, count in appearances.items():
284
+ if count < _MIN_SAMPLE or len(questions[source]) < _MIN_QUESTIONS:
285
+ continue
286
+ file_mean = weighted[source] / count
287
+ # Relative position against how an average result is ranked: 0 means
288
+ # the file sits at the graph-wide average, +1 means twice as high.
289
+ relative = (file_mean - mean_weight) / max(mean_weight, 1e-9)
290
+ estimate = max(-1.0, min(1.0, relative))
291
+ # Small samples are pulled toward zero; confidence reaches 0.5 at
292
+ # the minimum sample size and approaches 1 as evidence accumulates.
293
+ confidence = count / (count + _MIN_SAMPLE)
294
+ estimate *= confidence
295
+
296
+ old = feedback.get(source, 0.0)
297
+ new = max(-1.0, min(1.0, old * (1.0 - _BLEND) + estimate * _BLEND))
298
+ if abs(new - old) >= 0.01:
299
+ feedback[source] = round(new, 3)
300
+ updated += 1
301
+
302
+ if updated:
303
+ save_feedback(graph_dir, feedback)
304
+
305
+ if write_marker:
306
+ try:
307
+ marker.write_text("1", encoding="utf-8")
308
+ except OSError:
309
+ pass
310
+
311
+ return updated
312
+
313
+
314
+ def _analyze_unmarked_logs(graph_dir: Path, log_dir: Path) -> int:
315
+ """Analyze every past log that has no marker, oldest first."""
316
+ today = f"query_{datetime.now().strftime('%Y-%m-%d')}"
317
+ updated = 0
318
+ for log_path in sorted(log_dir.glob("query_*.jsonl")):
319
+ if log_path.stem == today:
320
+ continue # today's log is still being written
321
+ updated += analyze_daily_log(log_path, graph_dir)
322
+ return updated
323
+
324
+
325
+ def _maybe_run_daily_analysis(graph_dir: Path, log_dir: Path, on_complete=None) -> None:
326
+ """Run feedback analysis in the background.
327
+
328
+ The previous version only looked at *yesterday*; a day the server did
329
+ not run meant that log was never analyzed. Scanning unmarked logs makes
330
+ the feedback loop self-healing.
331
+ """
332
+
333
+ def _run():
334
+ try:
335
+ updated = _analyze_unmarked_logs(graph_dir, log_dir)
336
+ if updated > 0:
337
+ print(f"[feedback] updated {updated} file(s) from query logs", file=sys.stderr)
338
+ if on_complete:
339
+ on_complete()
340
+ except Exception as exc: # noqa: BLE001 - background thread must not die silently
341
+ print(f"[feedback] analysis failed: {exc}", file=sys.stderr)
342
+
343
+ threading.Thread(target=_run, daemon=True).start()
@@ -43,6 +43,7 @@ def write_entry(
43
43
  workspace: str,
44
44
  seed_ids: list[str] | None = None,
45
45
  result_node_ids: list[str] | None = None,
46
+ results: list[dict] | None = None,
46
47
  *,
47
48
  auto_rotate: bool = True,
48
49
  ) -> None:
@@ -51,6 +52,11 @@ def write_entry(
51
52
  auto_rotate: if True (default), spawn a background thread to compress
52
53
  old logs and clean up. Set to False when the caller manages rotation
53
54
  ordering (e.g. MCP server runs feedback analysis before rotation).
55
+
56
+ results: ordered structured entries ``{source_file, start_line,
57
+ end_line, item_ranges, node_ids}``. Feedback analysis needs the rank
58
+ order and the files, which cannot be recovered reliably from the
59
+ rendered text.
54
60
  """
55
61
  if not is_enabled():
56
62
  return
@@ -80,6 +86,8 @@ def write_entry(
80
86
  record["seed_ids"] = seed_ids
81
87
  if result_node_ids is not None:
82
88
  record["result_node_ids"] = result_node_ids
89
+ if results is not None:
90
+ record["results"] = results
83
91
 
84
92
  try:
85
93
  with log_file.open("a", encoding="utf-8") as f: