ltcai 9.9.4 → 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.
- package/README.md +43 -42
- package/docs/CHANGELOG.md +228 -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 +45 -9
- 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/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +28 -442
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/retrieval_vector.py +10 -0
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -0
- package/lattice_brain/quality.py +26 -6
- 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 +206 -48
- package/latticeai/api/chat_contracts.py +10 -1
- 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 +536 -60
- 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 +7 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +270 -23
- 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 +129 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +6 -3
- 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/scripts/run_sidecar_e2e.mjs +159 -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-BFbpl2_Y.js +1 -0
- package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
- package/static/app/assets/Brain-DGrV0heZ.js +321 -0
- package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
- package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
- package/static/app/assets/Capture-DRaf1zLH.js +1 -0
- package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
- package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
- package/static/app/assets/Library-BVtrSt12.js +1 -0
- package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
- package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
- package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
- package/static/app/assets/System-B4nQDy8a.js +1 -0
- package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
- package/static/app/assets/brain-wVV63_1z.js +1 -0
- package/static/app/assets/button-BTxZDMFJ.js +1 -0
- package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
- package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
- package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
- package/static/app/assets/download-EJS5_O0f.js +1 -0
- package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
- package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
- package/static/app/assets/index-BFtDYI6l.js +10 -0
- package/static/app/assets/index-BY9bpxyx.css +2 -0
- package/static/app/assets/input-DEz5UjBz.js +1 -0
- package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
- package/static/app/assets/primitives-jNrZm2O9.js +1 -0
- package/static/app/assets/search-ku6-_6wY.js +1 -0
- package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
- package/static/app/assets/textarea-DpcQkn75.js +1 -0
- package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
- package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
- package/static/app/assets/users-C0cFMj6o.js +1 -0
- package/static/app/assets/utils-DYf7pqrR.js +7 -0
- package/static/app/assets/workspace-CjthHsLN.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-BCmTU0E2.js +0 -1
- package/static/app/assets/Brain-CTnjox7w.js +0 -321
- package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
- package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
- package/static/app/assets/Capture-B6vBhFa3.js +0 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
- package/static/app/assets/Library-pKCK0_tk.js +0 -1
- package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
- package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
- package/static/app/assets/System-BAEuHqNY.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-DrmOCySv.js +0 -10
- package/static/app/assets/input-gtVCg-ll.js +0 -1
- package/static/app/assets/primitives-CX2Komon.js +0 -1
- package/static/app/assets/shield-alert-BftATuAA.js +0 -1
- package/static/app/assets/textarea-CiMJfOSI.js +0 -1
- package/static/app/assets/utils-SJUNVOj5.js +0 -7
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Voice memo capture — the fastest path from a thought to the Brain (v9.9.7).
|
|
2
|
+
|
|
3
|
+
Typing is the slowest part of capture. A 15-second voice memo on a phone or a
|
|
4
|
+
laptop is the shortest distance between "I should remember this" and the Brain
|
|
5
|
+
actually having it.
|
|
6
|
+
|
|
7
|
+
The hard constraint is local-first: a voice memo must never leave the machine
|
|
8
|
+
to become text. So transcription is an **injected, optional port**:
|
|
9
|
+
|
|
10
|
+
* when a local transcriber is available, the memo is transcribed and ingested
|
|
11
|
+
exactly like a typed note — same pipeline, same provenance, same quality
|
|
12
|
+
signals;
|
|
13
|
+
* when it is not, the audio is still stored and indexed by whatever the user
|
|
14
|
+
gave us (title, tags), and the result says ``transcription="unavailable"``
|
|
15
|
+
with the reason. **A missing transcriber never produces an invented
|
|
16
|
+
transcript**, and the memo is never silently dropped either.
|
|
17
|
+
|
|
18
|
+
Nothing here reaches for a cloud speech API, and nothing here installs a model
|
|
19
|
+
behind the user's back — an absent transcriber is a reported state, not a
|
|
20
|
+
prompt to download something.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Dict, Optional
|
|
28
|
+
|
|
29
|
+
LOGGER = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
__all__ = ["VoiceCaptureService", "TranscriptionUnavailable", "SUPPORTED_AUDIO_EXTENSIONS"]
|
|
32
|
+
|
|
33
|
+
# Container formats a local transcriber can plausibly open. Anything else is
|
|
34
|
+
# refused with a clear reason rather than handed to a transcriber that will
|
|
35
|
+
# fail obscurely.
|
|
36
|
+
SUPPORTED_AUDIO_EXTENSIONS = frozenset({
|
|
37
|
+
".m4a", ".mp3", ".wav", ".aac", ".flac", ".ogg", ".webm", ".mp4",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
# A memo is short by nature; a huge file is a recording session, not a memo,
|
|
41
|
+
# and would block the request for minutes.
|
|
42
|
+
MAX_AUDIO_BYTES = 50 * 1024 * 1024
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TranscriptionUnavailable(RuntimeError):
|
|
46
|
+
"""No local transcriber could turn this audio into text."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VoiceCaptureService:
|
|
50
|
+
"""Ingest a voice memo through the unified ingestion pipeline."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
pipeline: Any,
|
|
56
|
+
transcriber: Optional[Callable[[str], str]] = None,
|
|
57
|
+
max_bytes: int = MAX_AUDIO_BYTES,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._pipeline = pipeline
|
|
60
|
+
self._transcriber = transcriber
|
|
61
|
+
self._max_bytes = max(1, int(max_bytes))
|
|
62
|
+
|
|
63
|
+
# ── capability ───────────────────────────────────────────────────────
|
|
64
|
+
def status(self) -> Dict[str, Any]:
|
|
65
|
+
"""What this install can actually do with a voice memo, honestly."""
|
|
66
|
+
return {
|
|
67
|
+
"capture": self._pipeline is not None,
|
|
68
|
+
"transcription": self._transcriber is not None,
|
|
69
|
+
"supported_extensions": sorted(SUPPORTED_AUDIO_EXTENSIONS),
|
|
70
|
+
"max_bytes": self._max_bytes,
|
|
71
|
+
"detail": (
|
|
72
|
+
"음성 메모를 글로 바꿔 Brain에 저장합니다."
|
|
73
|
+
if self._transcriber is not None
|
|
74
|
+
else "이 컴퓨터에는 음성 인식기가 없어서, 메모는 저장되지만 글로 바뀌지는 않습니다."
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# ── transcription ────────────────────────────────────────────────────
|
|
79
|
+
def _transcribe(self, path: Path) -> str:
|
|
80
|
+
if self._transcriber is None:
|
|
81
|
+
raise TranscriptionUnavailable("no local transcriber is configured")
|
|
82
|
+
text = self._transcriber(str(path))
|
|
83
|
+
cleaned = str(text or "").strip()
|
|
84
|
+
if not cleaned:
|
|
85
|
+
# An empty transcript is not text: reporting it as one would put an
|
|
86
|
+
# empty note in the Brain and call it a memory.
|
|
87
|
+
raise TranscriptionUnavailable("the transcriber returned no text")
|
|
88
|
+
return cleaned
|
|
89
|
+
|
|
90
|
+
# ── capture ──────────────────────────────────────────────────────────
|
|
91
|
+
def capture(
|
|
92
|
+
self,
|
|
93
|
+
audio_path: str,
|
|
94
|
+
*,
|
|
95
|
+
title: Optional[str] = None,
|
|
96
|
+
user_email: Optional[str] = None,
|
|
97
|
+
workspace_id: Optional[str] = None,
|
|
98
|
+
conversation_id: Optional[str] = None,
|
|
99
|
+
transcript: Optional[str] = None,
|
|
100
|
+
) -> Dict[str, Any]:
|
|
101
|
+
"""Ingest one voice memo.
|
|
102
|
+
|
|
103
|
+
``transcript`` lets a client that already has text (a phone's own
|
|
104
|
+
dictation, for example) skip local transcription entirely — the memo
|
|
105
|
+
still lands in the Brain through the same pipeline.
|
|
106
|
+
"""
|
|
107
|
+
from lattice_brain.ingestion import IngestionItem
|
|
108
|
+
|
|
109
|
+
path = Path(str(audio_path or "")).expanduser()
|
|
110
|
+
if not path.exists() or not path.is_file():
|
|
111
|
+
return self._failed("FILE_NOT_FOUND", f"audio file not found: {path}")
|
|
112
|
+
suffix = path.suffix.lower()
|
|
113
|
+
if suffix not in SUPPORTED_AUDIO_EXTENSIONS:
|
|
114
|
+
return self._failed(
|
|
115
|
+
"UNSUPPORTED_FORMAT",
|
|
116
|
+
f"{suffix or 'this file'} is not a supported audio container",
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
size = path.stat().st_size
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
return self._failed("FILE_NOT_FOUND", f"audio file unreadable: {exc}")
|
|
122
|
+
if size > self._max_bytes:
|
|
123
|
+
return self._failed(
|
|
124
|
+
"SIZE_LIMIT",
|
|
125
|
+
f"audio is {size} bytes; the memo limit is {self._max_bytes}",
|
|
126
|
+
)
|
|
127
|
+
if self._pipeline is None:
|
|
128
|
+
return self._failed("UNAVAILABLE", "the ingestion pipeline is not available")
|
|
129
|
+
|
|
130
|
+
supplied = str(transcript or "").strip()
|
|
131
|
+
transcription_state = "supplied" if supplied else "ok"
|
|
132
|
+
transcription_detail = ""
|
|
133
|
+
text = supplied
|
|
134
|
+
if not text:
|
|
135
|
+
try:
|
|
136
|
+
text = self._transcribe(path)
|
|
137
|
+
except TranscriptionUnavailable as exc:
|
|
138
|
+
transcription_state = "unavailable"
|
|
139
|
+
transcription_detail = str(exc)
|
|
140
|
+
text = ""
|
|
141
|
+
except Exception as exc: # noqa: BLE001 — a broken transcriber is a state
|
|
142
|
+
LOGGER.exception("voice transcription failed")
|
|
143
|
+
transcription_state = "failed"
|
|
144
|
+
transcription_detail = str(exc)
|
|
145
|
+
text = ""
|
|
146
|
+
|
|
147
|
+
display_title = str(title or "").strip() or path.stem
|
|
148
|
+
# With no transcript there is no text to index — the memo is recorded
|
|
149
|
+
# by what we honestly know (title + the audio file itself), and the
|
|
150
|
+
# response says so. It is never presented as a searchable note.
|
|
151
|
+
body = text or (
|
|
152
|
+
f"[음성 메모] {display_title}\n"
|
|
153
|
+
"이 메모는 아직 글로 바뀌지 않았습니다 — 음성 인식기가 없어 내용 검색은 되지 않습니다."
|
|
154
|
+
)
|
|
155
|
+
item = IngestionItem(
|
|
156
|
+
source_type="note",
|
|
157
|
+
title=display_title,
|
|
158
|
+
text=body,
|
|
159
|
+
source_uri=str(path),
|
|
160
|
+
mime_type=f"audio/{suffix.lstrip('.')}",
|
|
161
|
+
owner=user_email,
|
|
162
|
+
workspace_id=workspace_id,
|
|
163
|
+
conversation_id=conversation_id,
|
|
164
|
+
metadata={
|
|
165
|
+
"capture": "voice",
|
|
166
|
+
"audio_path": str(path),
|
|
167
|
+
"audio_bytes": size,
|
|
168
|
+
"transcription": transcription_state,
|
|
169
|
+
**({"transcription_detail": transcription_detail} if transcription_detail else {}),
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
result = self._pipeline.ingest(item, user_email=user_email)
|
|
173
|
+
payload = result.as_dict() if hasattr(result, "as_dict") else dict(result)
|
|
174
|
+
payload["transcription"] = transcription_state
|
|
175
|
+
payload["searchable"] = bool(text)
|
|
176
|
+
if transcription_detail:
|
|
177
|
+
payload["transcription_detail"] = transcription_detail
|
|
178
|
+
return payload
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _failed(error: str, message: str) -> Dict[str, Any]:
|
|
182
|
+
return {
|
|
183
|
+
"status": "failed",
|
|
184
|
+
"source_type": "note",
|
|
185
|
+
"error": error,
|
|
186
|
+
"message": message,
|
|
187
|
+
"transcription": "skipped",
|
|
188
|
+
"searchable": False,
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "9.9.
|
|
3
|
+
"version": "9.9.9",
|
|
4
4
|
"description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"build:assets": "vite build && node scripts/build_frontend_assets.mjs",
|
|
25
25
|
"build:python": "node scripts/run_python.mjs -m build",
|
|
26
26
|
"check:python": "node scripts/run_python.mjs scripts/check_python.py",
|
|
27
|
-
"lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run check:bundle && npm run check:legacy-debt && npm run test:browser-extension",
|
|
27
|
+
"lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run check:i18n-namespaces && npm run check:bundle && npm run check:legacy-debt && npm run test:browser-extension && npm run test:vscode-extension",
|
|
28
28
|
"check:legacy-debt": "node scripts/check_legacy_debt.mjs",
|
|
29
29
|
"lint:python": "node scripts/run_python.mjs -m ruff check .",
|
|
30
30
|
"lint:frontend": "node scripts/lint_frontend.mjs",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"test:integration": "node scripts/run_integration_tests.mjs",
|
|
42
42
|
"test:browser-extension": "node --check browser-extension/popup.js && node --check browser-extension/capture-page.js && node --test tests/browser-extension.test.cjs",
|
|
43
43
|
"test:visual": "playwright test",
|
|
44
|
+
"test:e2e:sidecar": "node scripts/run_sidecar_e2e.mjs",
|
|
44
45
|
"vercel:build": "node scripts/build_vercel_static.mjs",
|
|
45
46
|
"desktop:tauri": "tauri dev",
|
|
46
47
|
"desktop:tauri:build": "tauri build",
|
|
@@ -56,7 +57,9 @@
|
|
|
56
57
|
"publish:pypi": "npm run build:python && node scripts/run_python.mjs -m twine upload --skip-existing dist/ltcai-$npm_package_version.tar.gz dist/ltcai-$npm_package_version-py3-none-any.whl",
|
|
57
58
|
"publish:vscode": "cd vscode-extension && npm run package:vsix && npm run publish:vscode",
|
|
58
59
|
"publish:openvsx": "cd vscode-extension && npm run package:vsix && npm run publish:openvsx",
|
|
59
|
-
"publish:all": "npm run release:artifacts && npm run release:validate && npm publish ltcai-$npm_package_version.tgz --access public && node scripts/run_python.mjs -m twine upload --skip-existing dist/ltcai-$npm_package_version.tar.gz dist/ltcai-$npm_package_version-py3-none-any.whl && cd vscode-extension && npm run publish:vscode && npm run publish:openvsx"
|
|
60
|
+
"publish:all": "npm run release:artifacts && npm run release:validate && npm publish ltcai-$npm_package_version.tgz --access public && node scripts/run_python.mjs -m twine upload --skip-existing dist/ltcai-$npm_package_version.tar.gz dist/ltcai-$npm_package_version-py3-none-any.whl && cd vscode-extension && npm run publish:vscode && npm run publish:openvsx",
|
|
61
|
+
"test:vscode-extension": "cd vscode-extension && npm run build && cd .. && node --test tests/vscode-extension.test.cjs",
|
|
62
|
+
"check:i18n-namespaces": "node scripts/check_i18n_namespace_coverage.mjs"
|
|
60
63
|
},
|
|
61
64
|
"keywords": [
|
|
62
65
|
"ltcai",
|
|
@@ -23,6 +23,17 @@ const manifestPath = join(outDir, "asset-manifest.json");
|
|
|
23
23
|
// pre-split baseline (632.9 kB raw / ~180 kB gzip single chunk) to lock in the
|
|
24
24
|
// code-splitting win and catch regressions that pull heavy code back onto the
|
|
25
25
|
// first-paint path. Raise it only with a deliberate, reviewed reason.
|
|
26
|
+
//
|
|
27
|
+
// 9.9.8 raised this to 152 KiB because `frontend/src/i18n/*` was one synchronous
|
|
28
|
+
// table, so every route's copy sat in the entry chunk and the tree had ~0.7 KiB
|
|
29
|
+
// of headroom left. 9.9.9 fixed the cause instead: namespaces register on
|
|
30
|
+
// import and each lazy route pulls only the copy it reads, which dropped the
|
|
31
|
+
// initial closure from 150.0 KiB to ~99 KiB gzip. The ceiling is back at its
|
|
32
|
+
// original 150 KiB with real headroom underneath it again.
|
|
33
|
+
//
|
|
34
|
+
// `scripts/check_i18n_namespace_coverage.mjs` guards the split — it fails if a
|
|
35
|
+
// module reads a key whose namespace its chunk never imports, which would
|
|
36
|
+
// otherwise render the raw key instead of translated text.
|
|
26
37
|
const INITIAL_JS_GZIP_BUDGET = 153_600; // 150 KiB
|
|
27
38
|
|
|
28
39
|
function build() {
|
|
@@ -6,7 +6,7 @@ const root = process.cwd();
|
|
|
6
6
|
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
7
7
|
const version = pkg.version;
|
|
8
8
|
const releaseDir = `output/release/v${version}`;
|
|
9
|
-
const releaseTheme = "
|
|
9
|
+
const releaseTheme = "Lean Shell";
|
|
10
10
|
const title = `${version} — ${releaseTheme}`;
|
|
11
11
|
const escapedVersion = version.replaceAll(".", "\\.");
|
|
12
12
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Proves the lazy i18n split is safe.
|
|
3
|
+
//
|
|
4
|
+
// `frontend/src/i18n/*` used to be one synchronous table merged into the entry
|
|
5
|
+
// chunk, so every route's copy sat on the first-paint path (~3,000 lines).
|
|
6
|
+
// Namespaces now register themselves on import, and a route pulls the namespace
|
|
7
|
+
// it needs into its own lazy chunk.
|
|
8
|
+
//
|
|
9
|
+
// The failure mode that buys is silent: a component reads a key whose namespace
|
|
10
|
+
// its chunk never imported, `t()` falls through to returning the raw key, and
|
|
11
|
+
// the UI renders "system.permission.title" instead of Korean text. No error, no
|
|
12
|
+
// test failure unless that exact component is rendered in a test.
|
|
13
|
+
//
|
|
14
|
+
// So this check walks the real module graph. For each entry — the eager root
|
|
15
|
+
// plus every React.lazy() boundary — it collects the static closure, every
|
|
16
|
+
// `t(lang, "key")` used in it, and every i18n namespace it imports. A key whose
|
|
17
|
+
// namespace is not reachable from that entry is an error.
|
|
18
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
19
|
+
import { join, dirname, resolve, relative } from "node:path";
|
|
20
|
+
import process from "node:process";
|
|
21
|
+
|
|
22
|
+
const repo = join(import.meta.dirname, "..");
|
|
23
|
+
const src = join(repo, "frontend", "src");
|
|
24
|
+
const i18nDir = join(src, "i18n");
|
|
25
|
+
|
|
26
|
+
const NAMESPACES = readdirSync(i18nDir)
|
|
27
|
+
.filter((name) => name.endsWith(".ts"))
|
|
28
|
+
.map((name) => name.replace(/\.ts$/, ""))
|
|
29
|
+
.filter((name) => !["types", "registry"].includes(name));
|
|
30
|
+
|
|
31
|
+
// key -> namespace that defines it
|
|
32
|
+
const keyOwner = new Map();
|
|
33
|
+
for (const ns of NAMESPACES) {
|
|
34
|
+
const text = readFileSync(join(i18nDir, `${ns}.ts`), "utf8");
|
|
35
|
+
for (const match of text.matchAll(/^\s+"([^"]+)":/gm)) keyOwner.set(match[1], ns);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `shell` is registered by i18n.ts itself, so it is always available.
|
|
39
|
+
const ALWAYS_AVAILABLE = new Set(["shell"]);
|
|
40
|
+
|
|
41
|
+
const STATIC_IMPORT = /^\s*import\s+(?!type\s)([^;]*?)from\s+"([^"]+)"/gm;
|
|
42
|
+
const BARE_IMPORT = /^\s*import\s+"([^"]+)"/gm;
|
|
43
|
+
const LAZY_IMPORT = /import\(\s*"([^"]+)"\s*\)/g;
|
|
44
|
+
const KEY_USE = /\bt\(\s*[A-Za-z_$][\w.$]*\s*,\s*"([^"]+)"/g;
|
|
45
|
+
|
|
46
|
+
function resolveSpec(spec, importer) {
|
|
47
|
+
let base;
|
|
48
|
+
if (spec.startsWith("@/")) base = join(src, spec.slice(2));
|
|
49
|
+
else if (spec.startsWith(".")) base = resolve(dirname(importer), spec);
|
|
50
|
+
else return null;
|
|
51
|
+
for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx")]) {
|
|
52
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readModule(file) {
|
|
58
|
+
const raw = readFileSync(file, "utf8");
|
|
59
|
+
// Drop `import { type X }`-only specifiers: TypeScript elides them, so they
|
|
60
|
+
// are not runtime edges and must not count as namespace coverage.
|
|
61
|
+
const statics = [];
|
|
62
|
+
for (const match of raw.matchAll(STATIC_IMPORT)) {
|
|
63
|
+
const clause = match[1];
|
|
64
|
+
const named = clause.match(/\{([^}]*)\}/);
|
|
65
|
+
const onlyTypes = named && named[1].trim().length > 0
|
|
66
|
+
&& named[1].split(",").every((part) => !part.trim() || part.trim().startsWith("type "))
|
|
67
|
+
&& !/^\s*\w+\s*,/.test(clause);
|
|
68
|
+
if (!onlyTypes) statics.push(match[2]);
|
|
69
|
+
}
|
|
70
|
+
for (const match of raw.matchAll(BARE_IMPORT)) statics.push(match[1]);
|
|
71
|
+
return {
|
|
72
|
+
statics,
|
|
73
|
+
lazy: [...raw.matchAll(LAZY_IMPORT)].map((m) => m[1]),
|
|
74
|
+
keys: [...raw.matchAll(KEY_USE)].map((m) => m[1]),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cache = new Map();
|
|
79
|
+
function moduleInfo(file) {
|
|
80
|
+
if (!cache.has(file)) cache.set(file, readModule(file));
|
|
81
|
+
return cache.get(file);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Static closure of an entry, not crossing further lazy boundaries. */
|
|
85
|
+
function closure(entry) {
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
const stack = [entry];
|
|
88
|
+
const lazyEdges = new Set();
|
|
89
|
+
while (stack.length) {
|
|
90
|
+
const file = stack.pop();
|
|
91
|
+
if (seen.has(file) || !existsSync(file)) continue;
|
|
92
|
+
seen.add(file);
|
|
93
|
+
const info = moduleInfo(file);
|
|
94
|
+
for (const spec of info.lazy) {
|
|
95
|
+
const target = resolveSpec(spec, file);
|
|
96
|
+
if (target) lazyEdges.add(target);
|
|
97
|
+
}
|
|
98
|
+
for (const spec of info.statics) {
|
|
99
|
+
const target = resolveSpec(spec, file);
|
|
100
|
+
if (target) stack.push(target);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { modules: seen, lazyEdges };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const root = join(src, "main.tsx");
|
|
107
|
+
const entries = new Map([["<entry> main.tsx", root]]);
|
|
108
|
+
const rootClosure = closure(root);
|
|
109
|
+
for (const target of rootClosure.lazyEdges) {
|
|
110
|
+
entries.set(`<lazy> ${relative(src, target)}`, target);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const errors = [];
|
|
114
|
+
for (const [label, entry] of entries) {
|
|
115
|
+
const { modules } = closure(entry);
|
|
116
|
+
const available = new Set(ALWAYS_AVAILABLE);
|
|
117
|
+
for (const file of modules) {
|
|
118
|
+
if (dirname(file) === i18nDir) {
|
|
119
|
+
const ns = relative(i18nDir, file).replace(/\.ts$/, "");
|
|
120
|
+
if (NAMESPACES.includes(ns)) available.add(ns);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const file of modules) {
|
|
124
|
+
if (dirname(file) === i18nDir) continue;
|
|
125
|
+
for (const key of moduleInfo(file).keys) {
|
|
126
|
+
const owner = keyOwner.get(key);
|
|
127
|
+
if (!owner) {
|
|
128
|
+
errors.push(`${label}: ${relative(src, file)} uses unknown key "${key}"`);
|
|
129
|
+
} else if (!available.has(owner)) {
|
|
130
|
+
errors.push(
|
|
131
|
+
`${label}: ${relative(src, file)} uses "${key}" from the "${owner}" namespace, `
|
|
132
|
+
+ `which this chunk never imports (add: import "@/i18n/${owner}";)`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (errors.length) {
|
|
140
|
+
console.error("i18n namespace coverage check failed:");
|
|
141
|
+
for (const error of [...new Set(errors)].sort()) console.error(`- ${error}`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(
|
|
146
|
+
`i18n namespace coverage: ${entries.size} entry chunk(s) verified against `
|
|
147
|
+
+ `${NAMESPACES.length} namespaces (${keyOwner.size} keys).`,
|
|
148
|
+
);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Start an isolated Lattice sidecar and run Playwright first-value E2E
|
|
4
|
+
* against it (Wave 3.2 residual / v9.9.5).
|
|
5
|
+
*
|
|
6
|
+
* Mirrors scripts/run_integration_tests.mjs isolation rules: never touch
|
|
7
|
+
* the developer's real HOME / ~/.ltcai / Brain vault.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const host = process.env.LTCAI_E2E_HOST || "127.0.0.1";
|
|
15
|
+
if (!new Set(["127.0.0.1", "localhost"]).has(host)) {
|
|
16
|
+
throw new Error(`LTCAI_E2E_HOST must be loopback, received: ${host}`);
|
|
17
|
+
}
|
|
18
|
+
const port = process.env.LTCAI_E2E_PORT || "4899";
|
|
19
|
+
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
|
|
20
|
+
throw new Error(`LTCAI_E2E_PORT must be 1–65535, received: ${port}`);
|
|
21
|
+
}
|
|
22
|
+
const baseUrl = `http://${host}:${port}`;
|
|
23
|
+
const venvPython = join(process.cwd(), ".venv", "bin", "python");
|
|
24
|
+
const python = process.env.PYTHON || (existsSync(venvPython) ? venvPython : "python3");
|
|
25
|
+
|
|
26
|
+
const sandboxRoot = mkdtempSync(join(tmpdir(), "ltcai-e2e-"));
|
|
27
|
+
const sandbox = {
|
|
28
|
+
home: join(sandboxRoot, "home"),
|
|
29
|
+
data: join(sandboxRoot, "data"),
|
|
30
|
+
brain: join(sandboxRoot, "brain"),
|
|
31
|
+
agent: join(sandboxRoot, "agent-workspace"),
|
|
32
|
+
vault: join(sandboxRoot, "vault"),
|
|
33
|
+
cache: join(sandboxRoot, "cache"),
|
|
34
|
+
config: join(sandboxRoot, "config"),
|
|
35
|
+
tmp: join(sandboxRoot, "tmp"),
|
|
36
|
+
};
|
|
37
|
+
for (const path of Object.values(sandbox)) mkdirSync(path, { recursive: true });
|
|
38
|
+
|
|
39
|
+
const isolatedEnv = {
|
|
40
|
+
...process.env,
|
|
41
|
+
HOME: sandbox.home,
|
|
42
|
+
USERPROFILE: sandbox.home,
|
|
43
|
+
XDG_CACHE_HOME: sandbox.cache,
|
|
44
|
+
XDG_CONFIG_HOME: sandbox.config,
|
|
45
|
+
XDG_DATA_HOME: join(sandboxRoot, "share"),
|
|
46
|
+
TMPDIR: sandbox.tmp,
|
|
47
|
+
TEMP: sandbox.tmp,
|
|
48
|
+
TMP: sandbox.tmp,
|
|
49
|
+
LATTICEAI_MODE: "local",
|
|
50
|
+
LATTICEAI_HOST: host,
|
|
51
|
+
LATTICEAI_PORT: port,
|
|
52
|
+
LATTICEAI_DATA_DIR: sandbox.data,
|
|
53
|
+
LATTICEAI_BRAIN_DIR: sandbox.brain,
|
|
54
|
+
LATTICEAI_AGENT_ROOT: sandbox.agent,
|
|
55
|
+
LATTICEAI_OBSIDIAN_VAULT_DIR: sandbox.vault,
|
|
56
|
+
LATTICEAI_STORAGE_ENGINE: "sqlite",
|
|
57
|
+
LATTICEAI_POSTGRES_DSN: "",
|
|
58
|
+
LATTICEAI_REQUIRE_AUTH: "false",
|
|
59
|
+
LATTICEAI_ENABLE_TELEGRAM: "false",
|
|
60
|
+
LATTICEAI_AUTOLOAD_MODELS: "false",
|
|
61
|
+
LATTICEAI_ALLOW_MODEL_DOWNLOADS: "false",
|
|
62
|
+
LATTICEAI_AUTO_READ_CHAT_PATHS: "false",
|
|
63
|
+
LATTICEAI_TUNNEL: "false",
|
|
64
|
+
LATTICEAI_DISCORD_PERMISSION_WEBHOOK: "",
|
|
65
|
+
LATTICEAI_DISCORD_BOT_TOKEN: "",
|
|
66
|
+
OIDC_DISCOVERY_URL: "",
|
|
67
|
+
PYTHON_KEYRING_BACKEND: "keyring.backends.null.Keyring",
|
|
68
|
+
LTCAI_E2E_BASE_URL: baseUrl,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
let sandboxCleaned = false;
|
|
72
|
+
function cleanupSandbox() {
|
|
73
|
+
if (sandboxCleaned) return;
|
|
74
|
+
sandboxCleaned = true;
|
|
75
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
process.once("exit", cleanupSandbox);
|
|
78
|
+
|
|
79
|
+
function delay(ms) {
|
|
80
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function waitForHealth(url, timeoutMs = 60000) {
|
|
84
|
+
const deadline = Date.now() + timeoutMs;
|
|
85
|
+
let lastError = "";
|
|
86
|
+
while (Date.now() < deadline) {
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) });
|
|
89
|
+
if (response.ok) return;
|
|
90
|
+
lastError = `HTTP ${response.status}`;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
lastError = String(error?.message || error);
|
|
93
|
+
}
|
|
94
|
+
await delay(500);
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`Timed out waiting for ${url}/health: ${lastError}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function stop(child) {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
102
|
+
resolve();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
107
|
+
}, 5000);
|
|
108
|
+
child.once("close", () => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
resolve();
|
|
111
|
+
});
|
|
112
|
+
child.kill("SIGTERM");
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function main() {
|
|
117
|
+
console.log(`[e2e] sandbox ${sandboxRoot}`);
|
|
118
|
+
console.log(`[e2e] starting sidecar at ${baseUrl}`);
|
|
119
|
+
// Same process model as scripts/run_integration_tests.mjs: uvicorn on
|
|
120
|
+
// server:app with an isolated env. Do not double-append /health — the
|
|
121
|
+
// wait helper already probes `${url}/health`.
|
|
122
|
+
const server = spawn(
|
|
123
|
+
python,
|
|
124
|
+
["-m", "uvicorn", "server:app", "--host", host, "--port", port],
|
|
125
|
+
{ stdio: "inherit", env: isolatedEnv, cwd: process.cwd() },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
let exitCode = 1;
|
|
129
|
+
try {
|
|
130
|
+
await waitForHealth(baseUrl);
|
|
131
|
+
console.log("[e2e] sidecar healthy — running Playwright");
|
|
132
|
+
// Playwright must use the host browser install (npx playwright install).
|
|
133
|
+
// The sidecar isolation rewrites HOME/XDG_* into a disposable sandbox —
|
|
134
|
+
// reusing that env here makes Chromium look under /tmp/.../ms-playwright
|
|
135
|
+
// and fail with "Executable doesn't exist".
|
|
136
|
+
const playwrightEnv = {
|
|
137
|
+
...process.env,
|
|
138
|
+
LTCAI_E2E_BASE_URL: baseUrl,
|
|
139
|
+
};
|
|
140
|
+
const playwright = spawn(
|
|
141
|
+
"npx",
|
|
142
|
+
["playwright", "test", "-c", "playwright.e2e.config.js"],
|
|
143
|
+
{ stdio: "inherit", env: playwrightEnv, cwd: process.cwd() },
|
|
144
|
+
);
|
|
145
|
+
exitCode = await new Promise((resolve, reject) => {
|
|
146
|
+
playwright.on("error", reject);
|
|
147
|
+
playwright.on("close", (code) => resolve(code ?? 1));
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error("[e2e] failed:", error);
|
|
151
|
+
exitCode = 1;
|
|
152
|
+
} finally {
|
|
153
|
+
await stop(server);
|
|
154
|
+
cleanupSandbox();
|
|
155
|
+
}
|
|
156
|
+
process.exit(exitCode);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Skill: meeting_notes
|
|
2
|
+
|
|
3
|
+
description: 회의록·메모에서 결정사항과 할 일을 뽑아 정리된 노트 파일로 저장한다.
|
|
4
|
+
|
|
5
|
+
## 메타데이터
|
|
6
|
+
- **버전**: 1.0.0
|
|
7
|
+
- **카테고리**: document
|
|
8
|
+
- **위험도**: low
|
|
9
|
+
- **필요 권한**: workspace_write
|
|
10
|
+
|
|
11
|
+
## 설명
|
|
12
|
+
|
|
13
|
+
붙여넣은 회의 메모(또는 워크스페이스의 메모 파일)를 읽어 **결정사항 / 할 일 /
|
|
14
|
+
미해결 질문**으로 갈라 정리하고, 워크스페이스에 마크다운 노트 한 장으로
|
|
15
|
+
저장한다. 저장된 파일은 다른 생성물과 동일하게 Brain에 수집되므로, 다음 주에
|
|
16
|
+
"지난 회의에서 뭘 정하기로 했지?"라고 물으면 근거와 함께 답이 나온다.
|
|
17
|
+
|
|
18
|
+
이 스킬이 "설치하자마자 값이 보이는" 이유: 입력이 사람이 이미 가지고 있는
|
|
19
|
+
것(회의 메모)이고, 출력이 사람이 이미 원하던 것(할 일 목록)이며, 중간에
|
|
20
|
+
설정할 것이 없다.
|
|
21
|
+
|
|
22
|
+
## 거버넌스
|
|
23
|
+
|
|
24
|
+
`risk.json` 참고. 요약: `risk=write`, `destructive=false`, `shell=false`,
|
|
25
|
+
`network=false`, `auto_approve=true`, `sandbox=workspace`, `rollback=snapshot`.
|
|
26
|
+
|
|
27
|
+
새 파일 생성은 additive라 바로 실행되고, **기존 파일을 덮어쓰는 경우에는
|
|
28
|
+
ChangeGovernor가 변경 제안으로 돌린다** — 승인 전에는 디스크가 바뀌지 않는다.
|
|
29
|
+
|
|
30
|
+
## 트리거 조건
|
|
31
|
+
|
|
32
|
+
호출해야 하는 상황:
|
|
33
|
+
- "회의록 정리해줘", "이 메모에서 할 일만 뽑아줘"
|
|
34
|
+
- 통화/미팅 직후 메모를 붙여넣었을 때
|
|
35
|
+
- 주간 회의 노트를 매번 같은 형식으로 남기고 싶을 때
|
|
36
|
+
|
|
37
|
+
호출하면 **안** 되는 상황:
|
|
38
|
+
- 문서 전체 요약이 목적일 때 → `summarize_document`
|
|
39
|
+
- 코드 리뷰 → `code_review`
|
|
40
|
+
- 표/수치 분석 → `data_analysis`
|
|
41
|
+
|
|
42
|
+
## Side Effects
|
|
43
|
+
|
|
44
|
+
| 항목 | 내용 |
|
|
45
|
+
|------|------|
|
|
46
|
+
| 파일 변경 | 대상 경로에 마크다운 1개 생성 (기존 파일이면 변경 제안) |
|
|
47
|
+
| 생성 파일 | `notes/<slug>.md` (기본) |
|
|
48
|
+
| 프로세스 | 없음 |
|
|
49
|
+
| 네트워크 | 없음 |
|
|
50
|
+
|
|
51
|
+
## Rollback
|
|
52
|
+
|
|
53
|
+
`rollback=snapshot`. 실행 실패 시 쓰기 전 스냅샷으로 복구하며, 이 실행이 새로
|
|
54
|
+
만든 파일은 삭제된다.
|
|
55
|
+
|
|
56
|
+
## 입력/출력 스키마
|
|
57
|
+
|
|
58
|
+
`schema.json` 참고.
|
|
59
|
+
|
|
60
|
+
## 실행 조건
|
|
61
|
+
|
|
62
|
+
- LLM 모델이 로드되어 있어야 함
|
|
63
|
+
- `notes` 텍스트 또는 읽을 수 있는 `source_path` 중 하나는 필수
|
|
64
|
+
|
|
65
|
+
## 정직성 규칙
|
|
66
|
+
|
|
67
|
+
- 메모에 없는 결정/할 일을 **만들어 내지 않는다.** 근거 문장이 없으면 그 항목은
|
|
68
|
+
넣지 않는다.
|
|
69
|
+
- 담당자·기한이 적혀 있지 않으면 비워 둔다. 추측해서 채우지 않는다.
|
|
70
|
+
- 결정사항이 하나도 없으면 "결정된 사항 없음"이라고 그대로 적는다.
|
|
71
|
+
|
|
72
|
+
## 예제
|
|
73
|
+
|
|
74
|
+
`examples.md` 참고.
|
|
75
|
+
|
|
76
|
+
## 실패 처리
|
|
77
|
+
|
|
78
|
+
| 에러 코드 | 원인 | 처리 방법 |
|
|
79
|
+
|-----------|------|-----------|
|
|
80
|
+
| `INVALID_INPUT` | notes와 source_path가 모두 없음 | 둘 중 하나 제공 |
|
|
81
|
+
| `FILE_NOT_FOUND` | source_path 없음 | 경로 확인 |
|
|
82
|
+
| `MODEL_NOT_LOADED` | 모델 미로드 | 모델을 먼저 로드 |
|