feat(case-files): פאנל "קבצי-התיק" בטאב טיוטות — גישה מה-UI לכל תתי-התיקיות
היו"ר לא יכל לגשת לטיוטה שהסוכן שמר בדיסק (interim-decision-draft.md) אלא ב-SSH.
מימוש מוקאפ X17 מאושר (18m): פאנל דפדפן-קבצים בטאב "טיוטות והערות".
Backend (web/app.py) — הכללה של ה-endpoints הקיימים ל**כל** תת-תיקייה:
- `_case_file_folders` — מונה כל תת-תיקיית-תוכן תחת התיק (מקור/מחולץ/מחקר/הגהה/
טיוטות/גיבויים), רקורסיה שכבה אחת לתוך documents/, מדלג על מוסתרות (.git/.claude)
ו-thumbnails. `_resolve_case_file` מאמת folder-key מול הרשימה + חוסם path-traversal.
- `/local-files` מחזיר את כל התיקיות (כולל ריקות, עם files=[]); `/local-files/{folder}/{filename}`
מגיש מכל תיקייה עם media-type לפי סיומת.
Frontend:
- `lib/api/case-files.ts` — useCaseFiles / useCaseFileText / caseFileUrl.
- `CaseFilesBrowser` — עץ-תיקיות (אקורדיון, ריקות מסומנות "ריק") + viewer מובנה
ל-md/txt (Markdown הקיים) + הורדה לכל קובץ. נטען בטאב "טיוטות והערות".
אין response_model → אין תלות ב-api:types (מודול טיפוסים בכתב-יד).
Invariants: G1, G2 (אין מסלול-קבצים מקביל — מכליל את הקיים), אבטחת-נתיב.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
123
web/app.py
123
web/app.py
@@ -3069,42 +3069,82 @@ async def api_learn(case_number: str):
|
||||
# ── Local files API — research, drafts, proofread ──
|
||||
|
||||
|
||||
# Directories under a case that are infrastructure/cache, not chair-facing
|
||||
# content. Hidden dirs (``.git``, ``.claude`` …) are skipped separately.
|
||||
_CASE_FILE_EXCLUDE_DIRS = {"thumbnails"}
|
||||
|
||||
|
||||
def _is_content_dir(p: Path) -> bool:
|
||||
return p.is_dir() and not p.name.startswith(".") and p.name not in _CASE_FILE_EXCLUDE_DIRS
|
||||
|
||||
|
||||
def _case_file_folders(case_dir: Path) -> list[tuple[str, Path]]:
|
||||
"""Enumerate every content subdirectory under a case dir as (key, path).
|
||||
|
||||
Covers *all* subfolders (not just research) — recursing one level into
|
||||
``documents/`` — while skipping hidden (``.git``/``.claude``…) and cache
|
||||
dirs. The ``key`` is the case-relative path with ``/`` encoded as ``__`` so
|
||||
it survives a single URL path segment; ``_resolve_case_file`` re-validates it
|
||||
against this same enumeration, so the key set is the serve allowlist (no path
|
||||
traversal).
|
||||
"""
|
||||
folders: list[tuple[str, Path]] = []
|
||||
if not case_dir.exists():
|
||||
return folders
|
||||
for child in sorted(case_dir.iterdir()):
|
||||
if not _is_content_dir(child):
|
||||
continue
|
||||
if child.name == "documents":
|
||||
folders.extend(
|
||||
(f"documents__{sub.name}", sub)
|
||||
for sub in sorted(child.iterdir())
|
||||
if _is_content_dir(sub)
|
||||
)
|
||||
else:
|
||||
folders.append((child.name, child))
|
||||
return folders
|
||||
|
||||
|
||||
def _resolve_case_file(case_dir: Path, folder_key: str, filename: str) -> Path | None:
|
||||
"""Safely resolve (folder_key, filename) to a file inside the case dir.
|
||||
|
||||
Returns None unless ``folder_key`` is one of the enumerated content folders
|
||||
AND ``filename`` resolves to a regular file directly inside it (no traversal).
|
||||
"""
|
||||
base = dict(_case_file_folders(case_dir)).get(folder_key)
|
||||
if base is None:
|
||||
return None
|
||||
target = (base / filename).resolve()
|
||||
if target.parent != base.resolve() or not target.is_file():
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/local-files")
|
||||
async def api_local_files(case_number: str):
|
||||
"""List local files from case subdirectories (research, drafts, proofread)."""
|
||||
"""List local files across ALL content subfolders under a case (not just
|
||||
research) — so the chair can reach any agent-produced file (drafts, OCR text,
|
||||
originals…) from the UI instead of only over SSH. Empty folders are returned
|
||||
too (with an empty ``files`` list) so the UI can show them as such."""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
result = {}
|
||||
for folder in ("research", "proofread"):
|
||||
folder_path = case_dir / "documents" / folder
|
||||
if folder_path.exists():
|
||||
files = []
|
||||
for f in sorted(folder_path.iterdir()):
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
stat = f.stat()
|
||||
files.append({
|
||||
"filename": f.name,
|
||||
"size": stat.st_size,
|
||||
"modified_at": stat.st_mtime,
|
||||
"folder": folder,
|
||||
})
|
||||
if files:
|
||||
result[folder] = files
|
||||
# Drafts are at case level, not under documents
|
||||
drafts_path = case_dir / "drafts"
|
||||
if drafts_path.exists():
|
||||
folders = []
|
||||
for key, path in _case_file_folders(case_dir):
|
||||
files = []
|
||||
for f in sorted(drafts_path.iterdir()):
|
||||
for f in sorted(path.iterdir()):
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
stat = f.stat()
|
||||
files.append({
|
||||
"filename": f.name,
|
||||
"size": stat.st_size,
|
||||
"modified_at": stat.st_mtime,
|
||||
"folder": "drafts",
|
||||
})
|
||||
if files:
|
||||
result["drafts"] = files
|
||||
return result
|
||||
folders.append({
|
||||
"key": key,
|
||||
"name": path.name,
|
||||
"path": str(path.relative_to(case_dir)),
|
||||
"files": files,
|
||||
})
|
||||
return {"folders": folders}
|
||||
|
||||
|
||||
async def serve_blob(
|
||||
@@ -3174,17 +3214,32 @@ async def _seal_blob_file(dest: Path, *, bucket=storage.Bucket.DOCUMENTS) -> Non
|
||||
|
||||
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
|
||||
async def api_read_local_file(case_number: str, folder: str, filename: str):
|
||||
"""Read contents of a local case file."""
|
||||
if folder not in ("research", "proofread", "drafts"):
|
||||
raise HTTPException(400, "Invalid folder")
|
||||
"""Serve a single local case file from any content subfolder.
|
||||
|
||||
``folder`` is a key from ``/local-files`` (``documents__research``, ``drafts``…);
|
||||
``_resolve_case_file`` validates it against the folder allowlist and blocks
|
||||
path traversal before the bytes are served.
|
||||
"""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if folder == "drafts":
|
||||
path = case_dir / "drafts" / filename
|
||||
else:
|
||||
path = case_dir / "documents" / folder / filename
|
||||
if not path.exists() or not path.is_file():
|
||||
path = _resolve_case_file(case_dir, folder, filename)
|
||||
if path is None:
|
||||
raise HTTPException(404, "קובץ לא נמצא")
|
||||
return await serve_blob(path, media_type="text/plain; charset=utf-8", filename=filename)
|
||||
media_type = _local_file_media_type(filename)
|
||||
return await serve_blob(path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
def _local_file_media_type(filename: str) -> str:
|
||||
"""Best-effort content type for a served case file (text renders inline,
|
||||
binaries download)."""
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
return {
|
||||
"md": "text/markdown; charset=utf-8",
|
||||
"txt": "text/plain; charset=utf-8",
|
||||
"json": "application/json; charset=utf-8",
|
||||
"pdf": "application/pdf",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"doc": "application/msword",
|
||||
}.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
# ── Research analysis (analysis-and-research.md) — parse + edit ────
|
||||
|
||||
Reference in New Issue
Block a user