ltcai 9.9.5 → 10.0.0
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.
- package/README.md +39 -37
- package/docs/CHANGELOG.md +234 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +3 -1
- package/docs/PERMISSION_MODE.md +107 -0
- package/docs/SURFACE_PARITY.md +38 -8
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/context.py +39 -4
- package/lattice_brain/graph/_kg_common.py +189 -14
- package/lattice_brain/graph/curator.py +62 -0
- package/lattice_brain/graph/discovery.py +76 -0
- package/lattice_brain/graph/ingest.py +37 -7
- package/lattice_brain/graph/retrieval_vector.py +10 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +8 -0
- package/latticeai/api/chat.py +9 -0
- package/latticeai/api/chat_agent_http.py +119 -2
- package/latticeai/api/chat_contracts.py +9 -0
- package/latticeai/api/chat_documents.py +16 -0
- package/latticeai/api/evidence_actions.py +48 -0
- package/latticeai/api/permission_mode.py +61 -0
- package/latticeai/api/project_sessions.py +112 -0
- package/latticeai/api/voice_capture.py +89 -0
- package/latticeai/app_factory.py +53 -0
- package/latticeai/core/agent.py +381 -43
- package/latticeai/core/agent_permission.py +160 -0
- package/latticeai/core/agent_profiles.py +110 -0
- package/latticeai/core/artifact_ledger.py +109 -0
- package/latticeai/core/context_builder.py +101 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/permission_mode.py +296 -0
- package/latticeai/core/project_sessions.py +337 -0
- package/latticeai/core/run_explain.py +421 -0
- package/latticeai/core/run_store.py +5 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +188 -0
- package/latticeai/runtime/chat_wiring.py +8 -0
- package/latticeai/runtime/context_runtime.py +11 -0
- package/latticeai/runtime/permission_mode_wiring.py +116 -0
- package/latticeai/runtime/router_registration.py +9 -0
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +105 -0
- package/latticeai/services/evidence_actions.py +254 -0
- package/latticeai/services/funnel_metrics.py +111 -16
- package/latticeai/services/local_knowledge.py +26 -0
- package/latticeai/services/memory_service.py +12 -0
- package/latticeai/services/permission_mode_service.py +170 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +73 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +6 -4
- package/scripts/check_bundle_budget.mjs +11 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +148 -0
- package/skills/meeting_notes/SKILL.md +82 -0
- package/skills/meeting_notes/examples.md +41 -0
- package/skills/meeting_notes/risk.json +9 -0
- package/skills/meeting_notes/schema.json +55 -0
- package/skills/weekly_review/SKILL.md +75 -0
- package/skills/weekly_review/examples.md +39 -0
- package/skills/weekly_review/risk.json +9 -0
- package/skills/weekly_review/schema.json +55 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -29
- package/static/app/assets/Act-BtCREeN1.js +1 -0
- package/static/app/assets/AdminConsole-TPeeN18T.js +1 -0
- package/static/app/assets/Brain-BKs6JAp0.js +321 -0
- package/static/app/assets/BrainHome-BPGOvSd6.js +2 -0
- package/static/app/assets/BrainSignals-CtzQZ15J.js +1 -0
- package/static/app/assets/Capture-1_NaHWqB.js +1 -0
- package/static/app/assets/CommandPalette-pqvQOXe4.js +1 -0
- package/static/app/assets/Library-DhvoPvC7.js +1 -0
- package/static/app/assets/{LivingBrain-BVWk58MW.js → LivingBrain-DlQ20Q75.js} +1 -1
- package/static/app/assets/ProductFlow-BZvGDRi_.js +1 -0
- package/static/app/assets/ReviewCard-BWgI0D2s.js +3 -0
- package/static/app/assets/System-CSMdYLMy.js +1 -0
- package/static/app/assets/activity-Dlfk8YC7.js +1 -0
- package/static/app/assets/{bot-VmP0kkeA.js → bot-CDvUB76P.js} +1 -1
- package/static/app/assets/brain-xczrohrt.js +1 -0
- package/static/app/assets/button-SOdH3Oyf.js +1 -0
- package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause-CG1ythH4.js} +1 -1
- package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-HXwvjS6W.js} +1 -1
- package/static/app/assets/{cpu-CJhjRtNq.js → cpu-B0d-rGyk.js} +1 -1
- package/static/app/assets/download-BGIkTQL6.js +1 -0
- package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-Dst_Z0_K.js} +1 -1
- package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-D53MsWkV.js} +1 -1
- package/static/app/assets/index-C_IrlQMV.js +10 -0
- package/static/app/assets/index-FxDusbr0.css +2 -0
- package/static/app/assets/{input-CSi6OBJ9.js → input-C5m0riF6.js} +1 -1
- package/static/app/assets/{network-Ck1nqc-v.js → network-C5a-E5iS.js} +1 -1
- package/static/app/assets/primitives-vNXYf58F.js +1 -0
- package/static/app/assets/search-DhbSgW6m.js +1 -0
- package/static/app/assets/shield-alert-Cc-WVXqN.js +1 -0
- package/static/app/assets/{textarea-C4wM0D-Z.js → textarea-BkZ0EqVO.js} +1 -1
- package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-CTtKbAOU.js} +1 -1
- package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-Dx1fi4Wu.js} +1 -1
- package/static/app/assets/users-BFpQXtEF.js +1 -0
- package/static/app/assets/utils-BA_lmW3J.js +7 -0
- package/static/app/assets/workspace-DBPB0jkX.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmxfbqBp.js +0 -1
- package/static/app/assets/Brain-DK_zKCda.js +0 -321
- package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
- package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
- package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
- package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
- package/static/app/assets/Library-4As9VUvy.js +0 -1
- package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
- package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
- package/static/app/assets/System-DSR2zKWd.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-CEu0Wqjl.js +0 -10
- package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
- package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
- 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
|
|
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
|
|