ltcai 9.9.5 → 9.9.9

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 (132) hide show
  1. package/README.md +41 -36
  2. package/docs/CHANGELOG.md +193 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +3 -1
  7. package/docs/PERMISSION_MODE.md +107 -0
  8. package/docs/SURFACE_PARITY.md +38 -8
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +39 -4
  14. package/lattice_brain/graph/_kg_common.py +189 -14
  15. package/lattice_brain/graph/curator.py +62 -0
  16. package/lattice_brain/graph/discovery.py +76 -0
  17. package/lattice_brain/graph/ingest.py +37 -7
  18. package/lattice_brain/graph/retrieval_vector.py +10 -0
  19. package/lattice_brain/ingestion.py +9 -106
  20. package/lattice_brain/ingestion_jobs.py +133 -0
  21. package/lattice_brain/runtime/multi_agent.py +1 -1
  22. package/latticeai/__init__.py +1 -1
  23. package/latticeai/api/brain_intelligence.py +8 -0
  24. package/latticeai/api/chat.py +9 -0
  25. package/latticeai/api/chat_agent_http.py +119 -2
  26. package/latticeai/api/chat_contracts.py +9 -0
  27. package/latticeai/api/chat_documents.py +16 -0
  28. package/latticeai/api/evidence_actions.py +48 -0
  29. package/latticeai/api/permission_mode.py +61 -0
  30. package/latticeai/api/project_sessions.py +112 -0
  31. package/latticeai/api/voice_capture.py +89 -0
  32. package/latticeai/app_factory.py +53 -0
  33. package/latticeai/core/agent.py +381 -43
  34. package/latticeai/core/agent_permission.py +160 -0
  35. package/latticeai/core/agent_profiles.py +110 -0
  36. package/latticeai/core/artifact_ledger.py +109 -0
  37. package/latticeai/core/context_builder.py +101 -2
  38. package/latticeai/core/legacy_compatibility.py +1 -1
  39. package/latticeai/core/marketplace.py +1 -1
  40. package/latticeai/core/permission_mode.py +296 -0
  41. package/latticeai/core/project_sessions.py +337 -0
  42. package/latticeai/core/run_explain.py +421 -0
  43. package/latticeai/core/run_store.py +5 -0
  44. package/latticeai/core/workspace_os.py +15 -80
  45. package/latticeai/core/workspace_review_items.py +107 -0
  46. package/latticeai/integrations/telegram_bot.py +188 -0
  47. package/latticeai/runtime/chat_wiring.py +8 -0
  48. package/latticeai/runtime/context_runtime.py +11 -0
  49. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  50. package/latticeai/runtime/router_registration.py +9 -0
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/brain_intelligence.py +105 -0
  54. package/latticeai/services/evidence_actions.py +254 -0
  55. package/latticeai/services/funnel_metrics.py +111 -16
  56. package/latticeai/services/local_knowledge.py +26 -0
  57. package/latticeai/services/memory_service.py +12 -0
  58. package/latticeai/services/permission_mode_service.py +170 -0
  59. package/latticeai/services/product_readiness.py +1 -1
  60. package/latticeai/services/tool_dispatch.py +73 -4
  61. package/latticeai/services/voice_capture.py +189 -0
  62. package/package.json +5 -3
  63. package/scripts/check_bundle_budget.mjs +11 -0
  64. package/scripts/check_current_release_docs.mjs +1 -1
  65. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  66. package/skills/meeting_notes/SKILL.md +82 -0
  67. package/skills/meeting_notes/examples.md +41 -0
  68. package/skills/meeting_notes/risk.json +9 -0
  69. package/skills/meeting_notes/schema.json +55 -0
  70. package/skills/weekly_review/SKILL.md +75 -0
  71. package/skills/weekly_review/examples.md +39 -0
  72. package/skills/weekly_review/risk.json +9 -0
  73. package/skills/weekly_review/schema.json +55 -0
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -29
  78. package/static/app/assets/Act-BFbpl2_Y.js +1 -0
  79. package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
  80. package/static/app/assets/Brain-DGrV0heZ.js +321 -0
  81. package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
  82. package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
  83. package/static/app/assets/Capture-DRaf1zLH.js +1 -0
  84. package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
  85. package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
  86. package/static/app/assets/Library-BVtrSt12.js +1 -0
  87. package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
  88. package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
  89. package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
  90. package/static/app/assets/System-B4nQDy8a.js +1 -0
  91. package/static/app/assets/{bot-VmP0kkeA.js → bot-DtKkLfXj.js} +1 -1
  92. package/static/app/assets/brain-wVV63_1z.js +1 -0
  93. package/static/app/assets/button-BTxZDMFJ.js +1 -0
  94. package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause--qxF9-EO.js} +1 -1
  95. package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-89x3S6jm.js} +1 -1
  96. package/static/app/assets/{cpu-CJhjRtNq.js → cpu-DTUI63vo.js} +1 -1
  97. package/static/app/assets/download-EJS5_O0f.js +1 -0
  98. package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-BL9zYTnc.js} +1 -1
  99. package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-BFnwi1Lu.js} +1 -1
  100. package/static/app/assets/index-BFtDYI6l.js +10 -0
  101. package/static/app/assets/index-BY9bpxyx.css +2 -0
  102. package/static/app/assets/input-DEz5UjBz.js +1 -0
  103. package/static/app/assets/{network-Ck1nqc-v.js → network-zKa3jAwl.js} +1 -1
  104. package/static/app/assets/primitives-jNrZm2O9.js +1 -0
  105. package/static/app/assets/search-ku6-_6wY.js +1 -0
  106. package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
  107. package/static/app/assets/textarea-DpcQkn75.js +1 -0
  108. package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-58TNCONM.js} +1 -1
  109. package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-CcVlXlBd.js} +1 -1
  110. package/static/app/assets/users-C0cFMj6o.js +1 -0
  111. package/static/app/assets/utils-DYf7pqrR.js +7 -0
  112. package/static/app/assets/workspace-CjthHsLN.js +1 -0
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/Act-DmxfbqBp.js +0 -1
  116. package/static/app/assets/Brain-DK_zKCda.js +0 -321
  117. package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
  118. package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
  119. package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
  120. package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
  121. package/static/app/assets/Library-4As9VUvy.js +0 -1
  122. package/static/app/assets/LivingBrain-BVWk58MW.js +0 -1
  123. package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
  124. package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
  125. package/static/app/assets/System-DSR2zKWd.js +0 -1
  126. package/static/app/assets/index-7FAfYm4v.css +0 -2
  127. package/static/app/assets/index-CEu0Wqjl.js +0 -10
  128. package/static/app/assets/input-CSi6OBJ9.js +0 -1
  129. package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
  130. package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
  131. package/static/app/assets/textarea-C4wM0D-Z.js +0 -1
  132. package/static/app/assets/utils-BqoznzGP.js +0 -7
@@ -0,0 +1,160 @@
1
+ """Agent-loop permission-mode gates (v9.9.8).
2
+
3
+ Kept separate from ``agent.py`` so the large state-machine module stays
4
+ stable; ``SingleAgentRuntime`` and ``build_agent_runtime`` call these
5
+ helpers instead of inlining mode tables.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, List, Mapping, Optional, Sequence
11
+
12
+ from latticeai.core.permission_mode import (
13
+ DEFAULT_MODE,
14
+ PermissionMode,
15
+ effective_auto_approve,
16
+ is_circuit_breaker,
17
+ normalize_mode,
18
+ plan_requires_approval,
19
+ )
20
+
21
+
22
+ def call_mode_source(
23
+ raw: Any,
24
+ *,
25
+ user_email: Optional[str] = None,
26
+ workspace_id: Optional[str] = None,
27
+ ) -> Any:
28
+ """Resolve a mode source that may be a value, or a callable that either
29
+ accepts ``user_email``/``workspace_id`` scope kwargs or takes no arguments.
30
+
31
+ Scoped resolution is what makes per-user and per-workspace overrides real:
32
+ an unscoped call always collapses to the process-wide default.
33
+ """
34
+ if not callable(raw):
35
+ return raw
36
+ try:
37
+ return raw(user_email=user_email, workspace_id=workspace_id)
38
+ except TypeError:
39
+ # Legacy zero-arg resolver (or a static callable) — fall back.
40
+ try:
41
+ return raw()
42
+ except Exception:
43
+ return DEFAULT_MODE
44
+ except Exception:
45
+ return DEFAULT_MODE
46
+
47
+
48
+ def resolve_deps_mode(
49
+ deps: Any,
50
+ ctx: Any = None,
51
+ *,
52
+ user_email: Optional[str] = None,
53
+ workspace_id: Optional[str] = None,
54
+ ) -> PermissionMode:
55
+ """Mode for this run: explicit context stamp wins, else the scoped resolver
56
+ on ``deps``, else strict."""
57
+ if ctx is not None:
58
+ override = getattr(ctx, "permission_mode", None)
59
+ if override is not None:
60
+ return normalize_mode(override)
61
+ raw = call_mode_source(
62
+ getattr(deps, "permission_mode", None),
63
+ user_email=user_email,
64
+ workspace_id=workspace_id,
65
+ )
66
+ return normalize_mode(raw if raw is not None else DEFAULT_MODE)
67
+
68
+
69
+ def non_auto_plan_steps(
70
+ mode: PermissionMode | str,
71
+ steps: Sequence[Mapping[str, Any]],
72
+ tool_governance: Mapping[str, Mapping[str, Any]],
73
+ *,
74
+ governed_tools: Optional[Any] = None,
75
+ ) -> List[Any]:
76
+ """Plan steps that still need approval under the active mode."""
77
+ mode = normalize_mode(mode)
78
+ governed = frozenset(governed_tools or ())
79
+ non_auto: List[Any] = []
80
+ for step in steps:
81
+ name = step.get("action")
82
+ if not name:
83
+ continue
84
+ if name in governed and mode == PermissionMode.STRICT:
85
+ # Strict keeps governor tools out of the plan-level block (per-call).
86
+ continue
87
+ policy = dict(tool_governance.get(name) or {
88
+ "auto_approve": False, "risk": "write", "destructive": False,
89
+ "shell": False, "network": False, "sandbox": "workspace", "rollback": "none",
90
+ })
91
+ if effective_auto_approve(mode, str(name), policy):
92
+ continue
93
+ non_auto.append(name)
94
+ return non_auto
95
+
96
+
97
+ def approval_requirements_for(
98
+ mode: PermissionMode | str,
99
+ plan: Mapping[str, Any],
100
+ tool_governance: Mapping[str, Mapping[str, Any]],
101
+ *,
102
+ governed_tools: Optional[Any] = None,
103
+ ) -> Dict[str, Any]:
104
+ steps = plan.get("steps") or []
105
+ non_auto = non_auto_plan_steps(
106
+ mode, steps, tool_governance, governed_tools=governed_tools,
107
+ )
108
+ requires = plan_requires_approval(
109
+ mode,
110
+ non_auto_steps=non_auto,
111
+ plan_flag=bool(plan.get("requires_approval", False)),
112
+ )
113
+ lines = [
114
+ f"{index}. {step.get('description') or step.get('action') or '?'}"
115
+ for index, step in enumerate(steps, start=1)
116
+ if isinstance(step, dict)
117
+ ]
118
+ summary = str(plan.get("goal") or "").strip()
119
+ if lines:
120
+ summary = (summary + "\n" if summary else "") + "\n".join(lines)
121
+ return {
122
+ "requires_approval": requires,
123
+ "non_auto_steps": non_auto,
124
+ "plan_summary": summary,
125
+ "permission_mode": normalize_mode(mode).value,
126
+ }
127
+
128
+
129
+ def block_reason_for_tool(
130
+ mode: PermissionMode | str,
131
+ name: str,
132
+ policy: Mapping[str, Any],
133
+ args: Mapping[str, Any],
134
+ *,
135
+ approved_by_human: bool = False,
136
+ governor_allows_additive: bool = False,
137
+ ) -> Optional[str]:
138
+ """Return a block reason, or None when the call may proceed."""
139
+ mode = normalize_mode(mode)
140
+ breaker = is_circuit_breaker(name, policy, args)
141
+ if breaker:
142
+ return f"BLOCKED: {breaker}"
143
+ if policy.get("risk") == "destructive" or policy.get("destructive"):
144
+ return f"BLOCKED: destructive action '{name}' not permitted in agent mode."
145
+ if approved_by_human or governor_allows_additive:
146
+ return None
147
+ if effective_auto_approve(mode, name, policy, args=args):
148
+ return None
149
+ if policy.get("auto_approve"):
150
+ return None
151
+ return f"BLOCKED: action '{name}' requires explicit approval (mode={mode.value})."
152
+
153
+
154
+ __all__ = [
155
+ "call_mode_source",
156
+ "resolve_deps_mode",
157
+ "non_auto_plan_steps",
158
+ "approval_requirements_for",
159
+ "block_reason_for_tool",
160
+ ]
@@ -0,0 +1,110 @@
1
+ """Agent profiles — matching the loop to the model driving it (v9.9.7).
2
+
3
+ The single-agent loop asks a model to emit one strict JSON tool call per turn.
4
+ Large models do that reliably; a 1–4B local model routinely does not, and the
5
+ loop's answer was always the same: repair, correct, retry, and eventually give
6
+ up with nothing written. The counters showed the strain (``model_strain`` in
7
+ the run explanation), but every model still ran the same loop.
8
+
9
+ A profile is that missing dial:
10
+
11
+ * **standard** — today's behaviour, unchanged, for models that can hold a
12
+ tool-call contract.
13
+ * **compact** — for small local models: a shorter transcript window, an
14
+ earlier escalation to naming the valid tools, and — the important one — a
15
+ **direct-path fallback**. When JSON tool calls keep failing, the loop stops
16
+ asking for JSON at all and executes the plan's own file steps, requesting
17
+ only *file content* in plain text. Weak models are bad at tool protocols and
18
+ fine at writing a file; the profile plays to that.
19
+
20
+ Selection is deterministic and inspectable: a model id names its size, so the
21
+ profile is a pure function of the id plus an explicit environment override.
22
+ Nothing here guesses at quality — a model absent from the size heuristic gets
23
+ ``standard``, which is the conservative choice (no behaviour change).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from dataclasses import dataclass
30
+ from typing import Mapping, Optional
31
+
32
+ __all__ = ["AgentProfile", "COMPACT", "STANDARD", "profile_for_model", "model_size_b"]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class AgentProfile:
37
+ """How hard the loop should work to keep a given model on contract."""
38
+
39
+ name: str
40
+ # Executor transcript window (steps kept in full). Small models drown in
41
+ # long transcripts far sooner than large ones.
42
+ transcript_window: int
43
+ # Format slips tolerated before the run stops retrying.
44
+ parse_failure_budget: int
45
+ # Slip count at which the correction hint starts naming valid tool names.
46
+ escalate_after: int
47
+ # When JSON tool calls are exhausted, execute the plan's file steps
48
+ # directly and ask only for file content in plain text.
49
+ direct_path_fallback: bool
50
+
51
+
52
+ STANDARD = AgentProfile(
53
+ name="standard",
54
+ transcript_window=8,
55
+ parse_failure_budget=3,
56
+ escalate_after=2,
57
+ direct_path_fallback=False,
58
+ )
59
+
60
+ COMPACT = AgentProfile(
61
+ name="compact",
62
+ transcript_window=4,
63
+ parse_failure_budget=4,
64
+ escalate_after=1,
65
+ direct_path_fallback=True,
66
+ )
67
+
68
+ _PROFILES = {profile.name: profile for profile in (STANDARD, COMPACT)}
69
+
70
+ # "gemma-3-4b-it-4bit", "qwen2.5-1.5b", "llama-3.2-3B" → parameter count in B.
71
+ # The quantization suffix ("4bit", "8bit") is deliberately excluded: it is not
72
+ # a parameter count, and reading it as one would mislabel every quantized model.
73
+ _SIZE_RE = re.compile(r"(?<![a-z0-9.])(\d+(?:\.\d+)?)\s*b(?![a-z0-9])", re.IGNORECASE)
74
+ _QUANT_RE = re.compile(r"\d+\s*bit", re.IGNORECASE)
75
+
76
+ # At or below this parameter count, the compact profile applies.
77
+ COMPACT_MAX_PARAMS_B = 4.0
78
+
79
+
80
+ def model_size_b(model_id: str) -> Optional[float]:
81
+ """Parameter count in billions parsed from a model id, or None.
82
+
83
+ Returns None rather than guessing when the id names no size — an unknown
84
+ model must not be silently downgraded to the compact loop.
85
+ """
86
+ text = _QUANT_RE.sub(" ", str(model_id or ""))
87
+ sizes = [float(match) for match in _SIZE_RE.findall(text)]
88
+ return min(sizes) if sizes else None
89
+
90
+
91
+ def profile_for_model(
92
+ model_id: Optional[str], *, env: Optional[Mapping[str, str]] = None
93
+ ) -> AgentProfile:
94
+ """Pick the loop profile for a model.
95
+
96
+ Order: explicit ``LATTICEAI_AGENT_PROFILE`` override → size heuristic →
97
+ ``standard``. An unrecognized override name falls through to the
98
+ heuristic rather than failing the run.
99
+ """
100
+ if env is None:
101
+ import os
102
+
103
+ env = os.environ
104
+ override = str(env.get("LATTICEAI_AGENT_PROFILE") or "").strip().lower()
105
+ if override in _PROFILES:
106
+ return _PROFILES[override]
107
+ size = model_size_b(model_id or "")
108
+ if size is not None and size <= COMPACT_MAX_PARAMS_B:
109
+ return COMPACT
110
+ return STANDARD
@@ -0,0 +1,109 @@
1
+ """Conversation artifact ledger — closing the re-search loop (v9.9.6).
2
+
3
+ A run writes ``index.html``, the file is queued for ingestion, and the user
4
+ immediately asks "이제 그 파일에 다크모드 넣어줘". Indexing (chunking, embedding,
5
+ graph writes) is asynchronous, so retrieval may not know about that file yet —
6
+ the next turn could plan as if it had never been created.
7
+
8
+ This ledger is the deterministic bridge: the agent path records what it just
9
+ wrote, and the context assembler injects it as a high-priority section. It is
10
+ intentionally **process-local and bounded**:
11
+
12
+ * it answers "what did *this conversation* just make?", a question whose
13
+ useful lifetime is minutes;
14
+ * by the time a restart loses it, the ingestion pipeline has indexed those
15
+ files and normal retrieval answers better than a ledger would;
16
+ * nothing downstream may treat it as durable storage — the Brain remains the
17
+ single durable home for knowledge.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import threading
23
+ from collections import OrderedDict
24
+ from typing import Any, Dict, List, Optional, Sequence
25
+
26
+ from latticeai.core.timeutil import now_iso
27
+
28
+ __all__ = ["ArtifactLedger"]
29
+
30
+
31
+ class ArtifactLedger:
32
+ """Bounded, thread-safe map of conversation → recently written artifacts."""
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ max_conversations: int = 200,
38
+ max_per_conversation: int = 25,
39
+ ) -> None:
40
+ self._lock = threading.Lock()
41
+ self._entries: "OrderedDict[tuple, List[Dict[str, Any]]]" = OrderedDict()
42
+ self._max_conversations = max(1, int(max_conversations))
43
+ self._max_per_conversation = max(1, int(max_per_conversation))
44
+
45
+ @staticmethod
46
+ def _key(
47
+ user_email: Optional[str],
48
+ conversation_id: Optional[str],
49
+ workspace_id: Optional[str],
50
+ ) -> tuple:
51
+ return (
52
+ str(user_email or ""),
53
+ str(conversation_id or "default"),
54
+ str(workspace_id or ""),
55
+ )
56
+
57
+ def record(
58
+ self,
59
+ paths: Sequence[Any],
60
+ *,
61
+ user_email: Optional[str] = None,
62
+ conversation_id: Optional[str] = None,
63
+ workspace_id: Optional[str] = None,
64
+ run_id: str = "",
65
+ ) -> List[Dict[str, Any]]:
66
+ """Note artifacts this conversation just produced. Never raises."""
67
+ cleaned: List[str] = []
68
+ for entry in list(paths or []):
69
+ path = entry if isinstance(entry, str) else (entry or {}).get("path")
70
+ path = str(path or "").strip()
71
+ if path and path not in cleaned:
72
+ cleaned.append(path)
73
+ if not cleaned:
74
+ return []
75
+ stamp = now_iso()
76
+ key = self._key(user_email, conversation_id, workspace_id)
77
+ with self._lock:
78
+ existing = self._entries.pop(key, [])
79
+ by_path = {item["path"]: item for item in existing}
80
+ for path in cleaned:
81
+ by_path[path] = {"path": path, "at": stamp, "run_id": str(run_id or "")}
82
+ merged = sorted(by_path.values(), key=lambda item: item["at"])
83
+ merged = merged[-self._max_per_conversation :]
84
+ self._entries[key] = merged
85
+ while len(self._entries) > self._max_conversations:
86
+ self._entries.popitem(last=False)
87
+ return list(merged)
88
+
89
+ def recent(
90
+ self,
91
+ *,
92
+ user_email: Optional[str] = None,
93
+ conversation_id: Optional[str] = None,
94
+ workspace_id: Optional[str] = None,
95
+ limit: int = 10,
96
+ ) -> List[Dict[str, Any]]:
97
+ """Most recent artifacts for one conversation, newest last.
98
+
99
+ Returns ``[]`` for an unknown conversation — honest absence, never a
100
+ cross-conversation leak.
101
+ """
102
+ key = self._key(user_email, conversation_id, workspace_id)
103
+ with self._lock:
104
+ items = list(self._entries.get(key, []))
105
+ return items[-max(1, int(limit)) :]
106
+
107
+ def clear(self) -> None:
108
+ with self._lock:
109
+ self._entries.clear()
@@ -5,18 +5,73 @@ retrieve_context_for_generation() 파이프라인:
5
5
  Step 1: Query → Hybrid Search (text + graph + recency)
6
6
  Step 2: Seed nodes → Multi-hop traversal (Document → Project → Concept)
7
7
  Step 3: Top-K 결과를 구조화된 Markdown Context로 변환
8
+
9
+ **Shared context contract (v9.9.6).** Chat assembles context through
10
+ :class:`lattice_brain.context.ContextAssembler` — budgeted, provenance-carrying,
11
+ with an honest ``context_quality`` signal. Document generation used to build
12
+ its own markdown with no budget and no quality signal, so the same Brain
13
+ answered a chat question and wrote a document under different guarantees.
14
+
15
+ The rendering stays different on purpose (a document prompt wants structured
16
+ sections, a chat prompt wants terse lines), but the *contract* is now one:
17
+
18
+ * the same ``approx_tokens`` accounting and an explicit budget,
19
+ * the same ``context_quality_signal`` shape chat reports,
20
+ * a ``trace`` in the assembler's shape (budget / used / sections).
21
+
22
+ Review 2026-07-27 P1 #5: "같은 Brain이면 같은 품질 계약".
8
23
  """
9
24
 
10
25
  import re
11
26
  from typing import Any, Dict, List
12
27
 
28
+ from lattice_brain.context import approx_tokens
29
+ from lattice_brain.graph.retrieval import context_quality_signal
30
+
13
31
  _CLEAN_RE = re.compile(r"\s+")
14
32
 
33
+ # Approximate-token ceiling for the document-generation context block. Mirrors
34
+ # the chat assembler's default budget so neither path can silently outgrow the
35
+ # other on the same Brain.
36
+ DEFAULT_DOCUMENT_CONTEXT_BUDGET = 2000
37
+
15
38
 
16
39
  def _clean(text: str, max_len: int = 700) -> str:
17
40
  return _CLEAN_RE.sub(" ", str(text or "")).strip()[:max_len]
18
41
 
19
42
 
43
+ def _empty_result(query: str, reason: str) -> Dict[str, Any]:
44
+ quality = context_quality_signal("none", 0, reason=reason)
45
+ return {
46
+ "query": query,
47
+ "context_markdown": "",
48
+ "sources": [],
49
+ "stats": {"method": "none", "matches": 0},
50
+ "context_quality": quality,
51
+ "trace": {
52
+ "budget_approx_tokens": DEFAULT_DOCUMENT_CONTEXT_BUDGET,
53
+ "used_approx_tokens": 0,
54
+ "sections": [],
55
+ },
56
+ }
57
+
58
+
59
+ def _fit_to_budget(context_md: str, budget: int) -> tuple:
60
+ """Trim rendered markdown to ``budget`` approximate tokens.
61
+
62
+ Cuts at a section boundary when possible so the prompt never ends inside
63
+ half an entry, and reports the trim so the trace stays honest.
64
+ """
65
+ if budget <= 0 or approx_tokens(context_md) <= budget:
66
+ return context_md, False
67
+ limit = budget * 4
68
+ head = context_md[:limit]
69
+ boundary = head.rfind("\n### ")
70
+ if boundary > limit // 3:
71
+ head = head[:boundary]
72
+ return head.rstrip(), True
73
+
74
+
20
75
  def retrieve_context_for_generation(
21
76
  kg_store,
22
77
  query: str,
@@ -25,6 +80,7 @@ def retrieve_context_for_generation(
25
80
  max_hops: int = 2,
26
81
  allowed_workspaces=None,
27
82
  include_legacy_global: bool = False,
83
+ budget: int = DEFAULT_DOCUMENT_CONTEXT_BUDGET,
28
84
  ) -> Dict[str, Any]:
29
85
  """Knowledge Graph에서 문서 생성에 필요한 컨텍스트를 검색·조합한다.
30
86
 
@@ -34,11 +90,13 @@ def retrieve_context_for_generation(
34
90
  "context_markdown": str, # LLM 프롬프트에 직접 주입할 Markdown
35
91
  "sources": [...], # 참조된 소스 목록
36
92
  "stats": {...}, # 검색 통계
93
+ "context_quality": {...}, # chat과 동일한 정직 신호 (v9.9.6)
94
+ "trace": {...}, # assembler와 동일한 예산/섹션 추적
37
95
  }
38
96
  """
39
97
  query = str(query or "").strip()
40
98
  if not query or not kg_store:
41
- return {"query": query, "context_markdown": "", "sources": [], "stats": {}}
99
+ return _empty_result(query, "문서 생성 컨텍스트를 조회할 수 없습니다")
42
100
 
43
101
  scope_kwargs = (
44
102
  {
@@ -55,11 +113,30 @@ def retrieve_context_for_generation(
55
113
  limit=max_results,
56
114
  **scope_kwargs,
57
115
  )
116
+ fallback_ctx, trimmed = _fit_to_budget(fallback_ctx or "", budget)
117
+ # Lexical fallback: the hybrid document search found nothing, so the
118
+ # signal says lexical_only — exactly what chat reports in the same
119
+ # situation, never a quiet downgrade.
120
+ quality = context_quality_signal(
121
+ "lexical_only" if fallback_ctx else "none",
122
+ 1 if fallback_ctx else 0,
123
+ )
58
124
  return {
59
125
  "query": query,
60
126
  "context_markdown": fallback_ctx,
61
127
  "sources": [],
62
- "stats": {"method": "fallback", "matches": 0},
128
+ "stats": {"method": "fallback", "matches": 0, "budget_trimmed": trimmed},
129
+ "context_quality": quality,
130
+ "trace": {
131
+ "budget_approx_tokens": budget,
132
+ "used_approx_tokens": approx_tokens(fallback_ctx),
133
+ "sections": [{
134
+ "name": "Knowledge (fallback)",
135
+ "source": "knowledge",
136
+ "approx_tokens": approx_tokens(fallback_ctx),
137
+ "provenance": [],
138
+ }],
139
+ },
63
140
  }
64
141
 
65
142
  seed_ids = [r["id"] for r in results]
@@ -77,6 +154,7 @@ def retrieve_context_for_generation(
77
154
  sections = _build_context_sections(results, extra_nodes_by_id, hop_data.get("edges", []))
78
155
  context_md = _render_markdown(query, sections)
79
156
  sources = _extract_sources(results)
157
+ context_md, trimmed = _fit_to_budget(context_md, budget)
80
158
 
81
159
  return {
82
160
  "query": query,
@@ -87,6 +165,27 @@ def retrieve_context_for_generation(
87
165
  "primary_matches": len(results),
88
166
  "graph_nodes": len(hop_data.get("nodes", [])),
89
167
  "graph_edges": len(hop_data.get("edges", [])),
168
+ "budget_trimmed": trimmed,
169
+ },
170
+ # Same honest signal chat reports for the same Brain.
171
+ "context_quality": context_quality_signal("hybrid", len(results)),
172
+ "trace": {
173
+ "budget_approx_tokens": budget,
174
+ "used_approx_tokens": approx_tokens(context_md),
175
+ "sections": [
176
+ {
177
+ "name": section["title"],
178
+ "source": "knowledge",
179
+ "approx_tokens": approx_tokens(
180
+ " ".join(str(item.get("summary") or "") for item in section["items"])
181
+ ),
182
+ "provenance": [
183
+ {"id": item.get("id"), "type": item.get("type")}
184
+ for item in section["items"][:8]
185
+ ],
186
+ }
187
+ for section in sections
188
+ ],
90
189
  },
91
190
  }
92
191
 
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
16
 
17
- LEGACY_COMPATIBILITY_VERSION = "9.9.5"
17
+ LEGACY_COMPATIBILITY_VERSION = "9.9.9"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "9.9.5"
14
+ MARKETPLACE_VERSION = "9.9.9"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17