zer0lint 0.1.0__py3-none-any.whl

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.
zer0lint/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """zer0lint — mem0 extraction optimizer."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "Hermes Labs"
5
+ __description__ = (
6
+ "Diagnostic and optimization tool for mem0 extraction pipelines. "
7
+ "Inspects your system, generates custom extraction prompts, tests them."
8
+ )
zer0lint/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for zer0lint CLI."""
2
+
3
+ from zer0lint.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
zer0lint/analyzer.py ADDED
@@ -0,0 +1,73 @@
1
+ """Generate extraction prompt from environment signals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ def analyze_with_llm(llm: object, environment_summary: str) -> str:
9
+ """
10
+ Use LLM to generate a domain-specific extraction prompt from environment signals.
11
+
12
+ Args:
13
+ llm: mem0's configured LLM instance (any provider)
14
+ environment_summary: Text describing the system environment (from scanner)
15
+
16
+ Returns:
17
+ A domain-specific extraction prompt tailored to the environment
18
+ """
19
+
20
+ analysis_prompt = f"""You are an expert at designing memory extraction prompts for AI agents.
21
+
22
+ Here is a description of the system environment you're designing a prompt for:
23
+
24
+ ---
25
+ {environment_summary}
26
+ ---
27
+
28
+ Based on this environment, write a mem0 custom_fact_extraction_prompt that will capture
29
+ the most important facts this system needs to remember.
30
+
31
+ The prompt should:
32
+ 1. Identify 5-8 specific categories of facts this system deals with
33
+ 2. Be specific to the actual work (not generic)
34
+ 3. Include 2-3 concrete examples (Input → Output in JSON)
35
+ 4. End with: Return facts as JSON with key "facts" and a list of strings. Extract generously.
36
+
37
+ Return ONLY the prompt text. No explanation, no markdown, no preamble. Start writing now:"""
38
+
39
+ try:
40
+ response = llm.chat_completion(
41
+ messages=[{"role": "user", "content": analysis_prompt}],
42
+ temperature=0.3,
43
+ )
44
+ if isinstance(response, dict):
45
+ prompt_text = response.get("message", response.get("content", ""))
46
+ else:
47
+ prompt_text = str(response)
48
+ return prompt_text.strip()
49
+ except Exception as e:
50
+ raise RuntimeError(f"LLM analysis failed: {e}")
51
+
52
+
53
+ def fallback_prompt_from_patterns(patterns: dict[str, list[str]]) -> str:
54
+ """
55
+ Generate a basic extraction prompt from detected patterns when LLM is unavailable.
56
+ """
57
+ if not patterns:
58
+ return (
59
+ "Extract all factual information from the conversation. "
60
+ "Return as JSON with key 'facts' and a list of strings. Extract generously."
61
+ )
62
+
63
+ categories_text = "\n".join(
64
+ f"{i+1}. {cat.capitalize()}: {', '.join(words)}"
65
+ for i, (cat, words) in enumerate(patterns.items())
66
+ )
67
+
68
+ return f"""You are a memory organizer for a system that works with {', '.join(patterns.keys())} data.
69
+
70
+ Extract ALL significant facts from the conversation. Focus on:
71
+ {categories_text}
72
+
73
+ Return facts as JSON with key "facts" and a list of strings. Extract generously."""
zer0lint/cli.py ADDED
@@ -0,0 +1,182 @@
1
+ """CLI for zer0lint v0.2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.syntax import Syntax
14
+ from rich.table import Table
15
+
16
+ from zer0lint import __version__
17
+ from zer0lint.fixer import detect_extraction_model
18
+ from zer0lint.orchestrator import run_check, run_generate
19
+
20
+ console = Console()
21
+ err_console = Console(stderr=True)
22
+
23
+ app = typer.Typer(help="zer0lint — AI memory extraction diagnostics")
24
+
25
+ DEFAULT_CONFIG_CANDIDATES = [
26
+ Path.home() / ".mem0" / "config.json",
27
+ Path.home() / ".mem0_config.json",
28
+ Path("config.json"),
29
+ ]
30
+
31
+
32
+ def _load_config(config_path: Optional[str]) -> tuple[dict, Optional[Path]]:
33
+ """Load mem0 config from path or auto-detect. Returns (config_dict, resolved_path)."""
34
+ if config_path:
35
+ p = Path(config_path)
36
+ if not p.exists():
37
+ err_console.print(f"[red]Config not found:[/red] {p}")
38
+ raise typer.Exit(1)
39
+ else:
40
+ p = next((c for c in DEFAULT_CONFIG_CANDIDATES if c.exists()), None)
41
+ if not p:
42
+ err_console.print(
43
+ "[red]No config found.[/red] Provide with --config (e.g., --config ~/.mem0/config.json)"
44
+ )
45
+ raise typer.Exit(1)
46
+
47
+ try:
48
+ with open(p) as f:
49
+ return json.load(f), p
50
+ except Exception as e:
51
+ err_console.print(f"[red]Error reading config:[/red] {e}")
52
+ raise typer.Exit(1)
53
+
54
+
55
+ @app.command()
56
+ def check(
57
+ config_path: Optional[str] = typer.Option(None, "--config", help="Path to mem0 config.json"),
58
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
59
+ n: int = typer.Option(5, "--facts", "-n", help="Number of test facts"),
60
+ ) -> None:
61
+ """
62
+ Check your current mem0 extraction pipeline health.
63
+
64
+ Tests your config as-is with N synthetic domain facts.
65
+ Shows recall score and status (HEALTHY / ACCEPTABLE / DEGRADED / CRITICAL).
66
+ """
67
+ config_dict, resolved = _load_config(config_path)
68
+ model = detect_extraction_model(config_dict)
69
+ has_custom = bool(config_dict.get("custom_fact_extraction_prompt"))
70
+
71
+ console.print(f"\n[bold]zer0lint v{__version__} — extraction health check[/bold]")
72
+ console.print(f"Config : {resolved}")
73
+ console.print(f"Model : {model}")
74
+ console.print(f"Prompt : {'custom' if has_custom else 'default (mem0 built-in)'}\n")
75
+
76
+ result = run_check(config_dict, verbose=verbose, n_facts=n)
77
+
78
+ color = {"HEALTHY": "green", "ACCEPTABLE": "cyan", "DEGRADED": "yellow", "CRITICAL": "red"}.get(
79
+ result["status"], "white"
80
+ )
81
+ console.print(
82
+ f"Score : [bold {color}]{result['score']}/{result['total']} ({result['pct']:.0f}%) — {result['status']}[/bold {color}]\n"
83
+ )
84
+
85
+ if not verbose:
86
+ for d in result["details"]:
87
+ icon = "✅" if d["found"] else ("⚠ " if d.get("stored") else "❌")
88
+ console.print(f" {icon} {d['label']}")
89
+
90
+ if result["status"] in ("DEGRADED", "CRITICAL"):
91
+ console.print(
92
+ "\n[yellow]Run [bold]zer0lint generate[/bold] to diagnose and fix.[/yellow]"
93
+ )
94
+ elif result["status"] == "ACCEPTABLE":
95
+ console.print("\n[cyan]Run [bold]zer0lint generate[/bold] to try improving to 5/5.[/cyan]")
96
+
97
+
98
+ @app.command()
99
+ def generate(
100
+ config_path: Optional[str] = typer.Option(None, "--config", help="Path to mem0 config.json"),
101
+ verbose: bool = typer.Option(True, "--verbose/--quiet", "-v/-q"),
102
+ apply: bool = typer.Option(True, "--apply/--dry-run", help="Apply fix to config"),
103
+ n: int = typer.Option(5, "--facts", "-n", help="Number of test facts"),
104
+ ) -> None:
105
+ """
106
+ Diagnose and fix your mem0 extraction pipeline.
107
+
108
+ Runs three phases:
109
+ 1. Baseline recall test (current config)
110
+ 2. Re-test with zer0lint technical extraction prompt (config-level)
111
+ 3. If improved → write validated prompt to your config
112
+
113
+ Example:
114
+ zer0lint generate --config ~/.mem0/config.json
115
+ zer0lint generate --config ~/.mem0/config.json --dry-run
116
+ """
117
+ config_dict, resolved = _load_config(config_path)
118
+ model = detect_extraction_model(config_dict)
119
+ has_custom = bool(config_dict.get("custom_fact_extraction_prompt"))
120
+
121
+ console.print(f"\n[bold]zer0lint v{__version__} — extraction optimizer[/bold]")
122
+ console.print(f"Config : {resolved}")
123
+ console.print(f"Model : {model}")
124
+ console.print(f"Prompt : {'custom' if has_custom else 'default (mem0 built-in)'}")
125
+ if not apply:
126
+ console.print("[yellow]Mode : dry-run (will not write to config)[/yellow]")
127
+ console.print()
128
+
129
+ result = run_generate(
130
+ base_config=config_dict,
131
+ config_path=resolved if apply else None,
132
+ verbose=verbose,
133
+ n_facts=n,
134
+ )
135
+
136
+ if not result["success"]:
137
+ err_console.print("[red]✗ Generate failed.[/red]")
138
+ raise typer.Exit(1)
139
+
140
+ if result.get("verdict") == "already_healthy":
141
+ console.print("\n[green]✅ Your extraction is already at 100%. No changes needed.[/green]")
142
+ raise typer.Exit(0)
143
+
144
+ # Show before/after
145
+ init_pct = result.get("initial_pct", 0)
146
+ impr_pct = result.get("improved_pct", 0)
147
+ imp_pp = result.get("improvement_pp", 0)
148
+
149
+ console.print(f"\n[bold]Results:[/bold]")
150
+ console.print(f" Before : {result['initial_score']}/{result.get('total', 5) if 'total' in result else 5} ({init_pct:.0f}%)")
151
+ console.print(f" After : {result['improved_score']}/{result.get('total', 5) if 'total' in result else 5} ({impr_pct:.0f}%)")
152
+ imp_color = "green" if imp_pp > 0 else "red"
153
+ console.print(f" Δ : [{imp_color}]{imp_pp:+.0f}pp[/{imp_color}]")
154
+
155
+ verdict = result.get("verdict")
156
+ if verdict == "improved" and result.get("applied"):
157
+ console.print(f"\n[green]✅ Fix applied to config.[/green]")
158
+ if result.get("backup_path"):
159
+ console.print(f" Backup: {result['backup_path']}")
160
+ console.print("\n[dim]Restart your agent to pick up the new extraction prompt.[/dim]")
161
+ elif verdict == "improved" and not result.get("applied"):
162
+ console.print(f"\n[cyan]Would improve by {imp_pp:+.0f}pp — run without --dry-run to apply.[/cyan]")
163
+ elif verdict == "no_improvement":
164
+ console.print(f"\n[yellow]⚠ zer0lint prompt did not improve recall on this config.[/yellow]")
165
+ console.print("[dim]Your current setup may already be optimized, or a different domain prompt is needed.[/dim]")
166
+ elif verdict == "below_threshold":
167
+ console.print(f"\n[yellow]⚠ Improvement detected but below threshold — not applying automatically.[/yellow]")
168
+
169
+
170
+ @app.callback(invoke_without_command=True)
171
+ def version_cb(
172
+ show_version: bool = typer.Option(None, "--version", is_eager=True, help="Show version"),
173
+ ctx: typer.Context = typer.Context,
174
+ ) -> None:
175
+ """zer0lint — AI memory extraction diagnostics."""
176
+ if show_version:
177
+ console.print(f"zer0lint v{__version__}")
178
+ raise typer.Exit(0)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ app()
zer0lint/fixer.py ADDED
@@ -0,0 +1,116 @@
1
+ """Apply generated extraction prompts to mem0 config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+
12
+ def backup_config(config_path: str | Path) -> str:
13
+ """
14
+ Backup the current config before modifying.
15
+
16
+ Args:
17
+ config_path: Path to mem0 config file
18
+
19
+ Returns:
20
+ Path to backup file
21
+ """
22
+ config_path = Path(config_path)
23
+ backup_path = config_path.parent / f"{config_path.stem}.backup.{datetime.now().isoformat()}"
24
+ shutil.copy2(config_path, backup_path)
25
+ return str(backup_path)
26
+
27
+
28
+ def apply_prompt(
29
+ config_path: str | Path, new_prompt: str, backup: bool = True
30
+ ) -> dict:
31
+ """
32
+ Apply a new extraction prompt to mem0 config file.
33
+
34
+ Args:
35
+ config_path: Path to mem0 config.json
36
+ new_prompt: The new extraction prompt to set
37
+ backup: Whether to backup the original config (default True)
38
+
39
+ Returns:
40
+ Dict with keys: success (bool), backup_path (str), config_path (str), changes (dict)
41
+ """
42
+ config_path = Path(config_path)
43
+
44
+ if not config_path.exists():
45
+ raise FileNotFoundError(f"Config not found: {config_path}")
46
+
47
+ # Read current config
48
+ with open(config_path) as f:
49
+ config = json.load(f)
50
+
51
+ # Backup
52
+ backup_path = None
53
+ if backup:
54
+ backup_path = backup_config(config_path)
55
+
56
+ # Record the old prompt for comparison
57
+ old_prompt = config.get("custom_fact_extraction_prompt", "(none)")
58
+
59
+ # Apply new prompt
60
+ config["custom_fact_extraction_prompt"] = new_prompt
61
+
62
+ # Write back
63
+ with open(config_path, "w") as f:
64
+ json.dump(config, f, indent=2)
65
+
66
+ return {
67
+ "success": True,
68
+ "config_path": str(config_path),
69
+ "backup_path": backup_path,
70
+ "changes": {
71
+ "field": "custom_fact_extraction_prompt",
72
+ "old_length": len(old_prompt),
73
+ "new_length": len(new_prompt),
74
+ },
75
+ }
76
+
77
+
78
+ def detect_extraction_model(config: dict) -> str:
79
+ """
80
+ Detect which LLM is configured for extraction in mem0 config.
81
+
82
+ Args:
83
+ config: Parsed mem0 config dict
84
+
85
+ Returns:
86
+ String describing the extraction model (e.g., "mistral:7b", "gpt-4o", "unknown")
87
+ """
88
+ # Most configs have an llm.config.model field
89
+ llm_config = config.get("llm", {})
90
+ if isinstance(llm_config, dict):
91
+ if "config" in llm_config:
92
+ model = llm_config["config"].get("model")
93
+ if model:
94
+ return model
95
+ if "model" in llm_config:
96
+ return llm_config["model"]
97
+
98
+ return "unknown"
99
+
100
+
101
+ def detect_vector_store(config: dict) -> str:
102
+ """
103
+ Detect which vector store is configured in mem0.
104
+
105
+ Args:
106
+ config: Parsed mem0 config dict
107
+
108
+ Returns:
109
+ String describing the vector store (e.g., "chroma", "qdrant", "unknown")
110
+ """
111
+ vs_config = config.get("vector_store", {})
112
+ if isinstance(vs_config, dict):
113
+ provider = vs_config.get("provider")
114
+ if provider:
115
+ return provider
116
+ return "unknown"
@@ -0,0 +1,240 @@
1
+ """Main orchestrator for zer0lint v0.2 — config-level injection, validated flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from zer0lint.fixer import apply_prompt, detect_extraction_model
11
+ from zer0lint.tester import (
12
+ generate_test_facts_for_categories,
13
+ validate_extraction_prompt,
14
+ cleanup_test_memories,
15
+ count_stored_memories,
16
+ )
17
+
18
+
19
+ # mem0's built-in default (personal assistant focused)
20
+ MEM0_DEFAULT_PROMPT = """You are a Personal Information Organizer. Extract facts from conversations.
21
+ Types: personal preferences, dates, relationships, activities, health, professional details.
22
+ Return as JSON: {"facts": ["fact1", ...]}
23
+ Input: Hi. Output: {"facts": []}"""
24
+
25
+ # zer0lint's technical-domain prompt (validated: 5/5 recall vs 2/5 with personal prompt)
26
+ TECHNICAL_EXTRACTION_PROMPT = """You are a Technical Memory Organizer for an AI agent workspace. Extract ALL factual statements from the input — technical decisions, infrastructure changes, product details, research findings, scores, dates, names, URLs, versions, and architectural choices.
27
+
28
+ Types of information to extract:
29
+ 1. Infrastructure changes (services started/stopped, configs changed, versions installed)
30
+ 2. Technical decisions and their rationale
31
+ 3. Product/project details (names, versions, stars, URLs, status)
32
+ 4. Research findings and experiment results (scores, percentages, benchmarks)
33
+ 5. People, organizations, and relationships
34
+ 6. Dates, deadlines, and timelines
35
+ 7. File paths, port numbers, model names, and system specifics
36
+ 8. Security findings and audit scores
37
+ 9. Architecture patterns and design decisions
38
+ 10. Task assignments and status changes
39
+
40
+ Examples:
41
+
42
+ Input: We forked OpenClaw and installed it as v2026.3.14
43
+ Output: {"facts": ["Forked OpenClaw, installed as version 2026.3.14"]}
44
+
45
+ Input: The security audit scored 7.2 out of 10, up from 6.1
46
+ Output: {"facts": ["Security audit score: 7.2/10", "Previous security audit score was 6.1"]}
47
+
48
+ Input: Little Canary runs as HTTP server on port 18421 in full blocking mode
49
+ Output: {"facts": ["Little Canary runs as HTTP server on port 18421", "Little Canary is in full blocking mode"]}
50
+
51
+ Input: Hi, how are you?
52
+ Output: {"facts": []}
53
+
54
+ Return facts as JSON with key "facts" and a list of strings. Extract generously — it's better to capture too much than too little. Every concrete fact matters."""
55
+
56
+
57
+ def _make_memory(base_config: dict, custom_prompt: Optional[str] = None, collection_suffix: str = "test"):
58
+ """Create a mem0 Memory instance with optional config-level prompt injection."""
59
+ from mem0 import Memory
60
+
61
+ config = json.loads(json.dumps(base_config)) # deep copy
62
+
63
+ # Swap collection to an isolated test namespace
64
+ if "vector_store" in config and "config" in config["vector_store"]:
65
+ orig_name = config["vector_store"]["config"].get("collection_name", "mem0")
66
+ config["vector_store"]["config"]["collection_name"] = f"{orig_name}_{collection_suffix}"
67
+
68
+ # Config-level prompt injection (the correct way in mem0 v1.x)
69
+ if custom_prompt:
70
+ config["custom_fact_extraction_prompt"] = custom_prompt
71
+ elif "custom_fact_extraction_prompt" in config:
72
+ del config["custom_fact_extraction_prompt"]
73
+
74
+ return Memory.from_config(config)
75
+
76
+
77
+ def run_check(
78
+ base_config: dict,
79
+ verbose: bool = False,
80
+ n_facts: int = 5,
81
+ ) -> dict:
82
+ """
83
+ Phase 1: Baseline recall test.
84
+ Tests current config as-is against domain-relevant synthetic facts.
85
+
86
+ Returns dict: score, total, pct, status, details
87
+ """
88
+ if verbose:
89
+ model = detect_extraction_model(base_config)
90
+ print(f"[CHECK] Using model: {model}")
91
+ print(f"[CHECK] Testing with {n_facts} synthetic facts...")
92
+
93
+ memory = _make_memory(base_config, collection_suffix="check")
94
+ uid = "zer0lint_check"
95
+ cleanup_test_memories(memory, user_id=uid)
96
+
97
+ facts = generate_test_facts_for_categories(["technical", "research"], count=n_facts)
98
+ results = validate_extraction_prompt(memory, facts, "", user_id=uid, wait_seconds=1.5)
99
+
100
+ score = results["score"]
101
+ total = results["total"]
102
+ pct = score / total * 100 if total > 0 else 0
103
+
104
+ if pct >= 80:
105
+ status = "HEALTHY"
106
+ elif pct >= 60:
107
+ status = "ACCEPTABLE"
108
+ elif pct >= 40:
109
+ status = "DEGRADED"
110
+ else:
111
+ status = "CRITICAL"
112
+
113
+ if verbose:
114
+ print(f"[CHECK] Score: {score}/{total} ({pct:.0f}%) — {status}")
115
+ for d in results["details"]:
116
+ icon = "✅" if d["found"] else ("⚠ " if d.get("stored") else "❌")
117
+ print(f" {icon} {d['label']}: {d['text'][:55]}...")
118
+
119
+ return {
120
+ "score": score,
121
+ "total": total,
122
+ "pct": pct,
123
+ "status": status,
124
+ "details": results["details"],
125
+ "failures": results["failures"],
126
+ }
127
+
128
+
129
+ def run_generate(
130
+ base_config: dict,
131
+ config_path: Optional[str | Path] = None,
132
+ verbose: bool = False,
133
+ n_facts: int = 5,
134
+ ) -> dict:
135
+ """
136
+ Full zer0lint v0.2 generate flow (3 phases):
137
+ Phase 1: Baseline recall test (current config)
138
+ Phase 2: Re-test with zer0lint technical prompt (config-level injection)
139
+ Phase 3: If improved → write to config
140
+
141
+ Args:
142
+ base_config: Parsed mem0 config dict
143
+ config_path: Path to mem0 config.json (for applying the fix)
144
+ verbose: Print detailed output
145
+ n_facts: Number of test facts per run
146
+
147
+ Returns dict with: initial_score, improved_score, improvement_pp, applied, prompt, status
148
+ """
149
+ result = {
150
+ "success": False,
151
+ "initial_score": None,
152
+ "initial_pct": None,
153
+ "improved_score": None,
154
+ "improved_pct": None,
155
+ "improvement_pp": None,
156
+ "prompt": None,
157
+ "applied": False,
158
+ "backup_path": None,
159
+ "verdict": None,
160
+ }
161
+
162
+ facts = generate_test_facts_for_categories(["technical", "research"], count=n_facts)
163
+ uid_baseline = "zer0lint_baseline"
164
+ uid_improved = "zer0lint_improved"
165
+
166
+ # --- Phase 1: Baseline (current config, no changes) ---
167
+ if verbose:
168
+ print("\n[1/3] Baseline — testing current config as-is...")
169
+
170
+ mem_baseline = _make_memory(base_config, collection_suffix="baseline")
171
+ cleanup_test_memories(mem_baseline, user_id=uid_baseline)
172
+ res_baseline = validate_extraction_prompt(mem_baseline, facts, "", user_id=uid_baseline, wait_seconds=1.5)
173
+
174
+ initial_score = res_baseline["score"]
175
+ initial_pct = initial_score / n_facts * 100
176
+ result["initial_score"] = initial_score
177
+ result["initial_pct"] = initial_pct
178
+
179
+ if verbose:
180
+ print(f" Baseline score: {initial_score}/{n_facts} ({initial_pct:.0f}%)")
181
+ for d in res_baseline["details"]:
182
+ icon = "✅" if d["found"] else "❌"
183
+ print(f" {icon} {d['label']}")
184
+
185
+ # If already perfect, done
186
+ if initial_score >= n_facts:
187
+ if verbose:
188
+ print("\n✅ Extraction is already perfect. No changes needed.")
189
+ result["success"] = True
190
+ result["verdict"] = "already_healthy"
191
+ return result
192
+
193
+ # --- Phase 2: Re-test with zer0lint technical prompt (config-level) ---
194
+ if verbose:
195
+ print("\n[2/3] Re-testing with zer0lint technical extraction prompt (config-level)...")
196
+
197
+ mem_improved = _make_memory(base_config, custom_prompt=TECHNICAL_EXTRACTION_PROMPT, collection_suffix="improved")
198
+ cleanup_test_memories(mem_improved, user_id=uid_improved)
199
+ res_improved = validate_extraction_prompt(mem_improved, facts, "", user_id=uid_improved, wait_seconds=1.5)
200
+
201
+ improved_score = res_improved["score"]
202
+ improved_pct = improved_score / n_facts * 100
203
+ improvement_pp = improved_pct - initial_pct
204
+ result["improved_score"] = improved_score
205
+ result["improved_pct"] = improved_pct
206
+ result["improvement_pp"] = improvement_pp
207
+ result["prompt"] = TECHNICAL_EXTRACTION_PROMPT
208
+
209
+ if verbose:
210
+ print(f" Improved score: {improved_score}/{n_facts} ({improved_pct:.0f}%)")
211
+ print(f" Improvement: {improvement_pp:+.0f}pp")
212
+ for d in res_improved["details"]:
213
+ icon = "✅" if d["found"] else "❌"
214
+ print(f" {icon} {d['label']}")
215
+
216
+ # --- Phase 3: Apply if improved and above threshold ---
217
+ if improvement_pp > 0 and improved_score >= max(initial_score, 4):
218
+ result["verdict"] = "improved"
219
+ if config_path:
220
+ if verbose:
221
+ print(f"\n[3/3] Applying fix to config ({initial_pct:.0f}% → {improved_pct:.0f}%)...")
222
+ apply_result = apply_prompt(config_path, TECHNICAL_EXTRACTION_PROMPT, backup=True)
223
+ result["applied"] = apply_result["success"]
224
+ result["backup_path"] = apply_result.get("backup_path")
225
+ if verbose:
226
+ print(f" ✅ Config updated. Backup at: {apply_result.get('backup_path')}")
227
+ else:
228
+ if verbose:
229
+ print(f"\n[3/3] Would apply fix but no --config path given (dry run).")
230
+ elif improvement_pp <= 0:
231
+ result["verdict"] = "no_improvement"
232
+ if verbose:
233
+ print(f"\n⚠ Prompt did not improve score — not applying.")
234
+ else:
235
+ result["verdict"] = "below_threshold"
236
+ if verbose:
237
+ print(f"\n⚠ Score improved but still below threshold — not applying.")
238
+
239
+ result["success"] = True
240
+ return result