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.
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/PKG-INFO +1 -1
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/__main__.py +59 -35
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/build_cmd.py +18 -0
- graphify_vault-0.6.2/graphify/feedback.py +343 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/query_log.py +8 -0
- graphify_vault-0.6.2/graphify/retrieval_metrics.py +286 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/serve.py +1580 -401
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/simplify.py +46 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/PKG-INFO +1 -1
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/SOURCES.txt +6 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/pyproject.toml +3 -1
- graphify_vault-0.6.2/tests/test_feedback.py +351 -0
- graphify_vault-0.6.2/tests/test_query_cache.py +486 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_query_determinism.py +0 -1
- graphify_vault-0.6.2/tests/test_query_snippet.py +88 -0
- graphify_vault-0.6.2/tests/test_query_variants.py +57 -0
- graphify_vault-0.6.2/tests/test_rerank.py +703 -0
- graphify_vault-0.6.2/tests/test_retrieval_metrics.py +158 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_serve.py +24 -12
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_simplify.py +84 -0
- graphify_vault-0.6.2/tests/test_source_context.py +89 -0
- graphify_vault-0.6.0/graphify/feedback.py +0 -149
- graphify_vault-0.6.0/tests/test_feedback.py +0 -268
- graphify_vault-0.6.0/tests/test_rerank.py +0 -259
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/LICENSE +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/README.md +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/__init__.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/_minhash.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/affected.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/analyze.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/audit.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/benchmark.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/bin_cache.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/build.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/cache.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/cluster.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/compat.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/compile.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/dedup.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/detect.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/doctor.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/export.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/extract.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/hooks.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/ingest.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/llm.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/manifest.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/mcp_cmd.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/multigraph_compat.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/report.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/security.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/skill.md +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/transcribe.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/validate.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/watch.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify/wiki.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/dependency_links.txt +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/entry_points.txt +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/requires.txt +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/graphify_vault.egg-info/top_level.txt +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/setup.cfg +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_affected.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_analyze.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_audit.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_benchmark.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_bin_cache.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_build.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_build_multigraph.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_cache.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_claude_md.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_cluster.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_cluster_multigraph.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_compile.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_confidence.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_dedup.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_detect.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_export.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_export_multigraph.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_extract.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_extract_headingless.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_graph_build_perf.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_heading_cluster.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_heading_compat.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_heading_extract.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_heading_query.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_hooks.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_hypergraph.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_ingest.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_install.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_languages.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_llm.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_main_multigraph.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_mcp_cmd.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_multigraph_compat.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_multigraph_edge_compat.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_multilang.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_pipeline.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_query_log.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_rationale.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_report.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_security.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_semantic_similarity.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_serve_multigraph.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_serve_multigraph_load.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_sort_by.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_spatial_craft_build.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_spatialsdk_query_regression.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_transcribe.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_validate.py +0 -0
- {graphify_vault-0.6.0 → graphify_vault-0.6.2}/tests/test_watch.py +0 -0
- {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.
|
|
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
|
|
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(" --
|
|
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] [--
|
|
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] == "--
|
|
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
|
|
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
|
|
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
|
|
2104
|
+
elif a in ("--list", "list"):
|
|
2112
2105
|
action = "list"
|
|
2113
2106
|
i += 1
|
|
2114
|
-
elif a
|
|
2107
|
+
elif a in ("--clear", "clear"):
|
|
2115
2108
|
action = "clear"
|
|
2116
2109
|
i += 1
|
|
2117
|
-
elif a
|
|
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 == "
|
|
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:+.
|
|
2130
|
-
print(f" ({len(fb)}
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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
|
|
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
|
|
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:+.
|
|
2186
|
+
print(f" {node_id}: score={score:+.2f} multiplier={feedback_multiplier(score):.2f}")
|
|
2166
2187
|
else:
|
|
2167
|
-
print("Usage: graphify feedback [--list | --clear | --analyze | --
|
|
2168
|
-
print("
|
|
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(" --
|
|
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:
|