Spaces:
Running
Running
| """ | |
| Evidence Case Management System. | |
| Organises forensic analysis results into named investigation cases. | |
| Cases are persisted to an append-only JSONL file (same pattern as audit_log). | |
| A case contains: | |
| - case_id UUID, immutable | |
| - name Human-readable label | |
| - description Optional free-text | |
| - created_at ISO timestamp | |
| - status open | closed | archived | |
| - evidence List of evidence_id references with metadata | |
| - tags Free-form list of strings | |
| Operations: | |
| create_case Create new case, return case_id | |
| add_evidence Attach an evidence_id (from forensic report) to a case | |
| get_case Retrieve full case with all evidence summaries | |
| list_cases List all cases with status filter | |
| update_status Change case status | |
| search_cases Full-text search across name, description, tags | |
| Storage: | |
| cases.jsonl — append-only case log (never overwritten) | |
| Each line is one full case snapshot. Last snapshot for each case_id wins. | |
| """ | |
| import json | |
| import uuid | |
| import logging | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Dict, Any, List, Optional | |
| logger = logging.getLogger(__name__) | |
| CASES_PATH = Path(__file__).parent.parent / "data" / "cases.jsonl" | |
| _VALID_STATUSES = {"open", "closed", "archived"} | |
| def _now() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| def _load_all_cases() -> Dict[str, Dict[str, Any]]: | |
| """Load all cases from JSONL. Last snapshot per case_id wins.""" | |
| cases: Dict[str, Dict[str, Any]] = {} | |
| if not CASES_PATH.exists(): | |
| return cases | |
| try: | |
| for line in CASES_PATH.read_text(encoding="utf-8").splitlines(): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| case = json.loads(line) | |
| cid = case.get("case_id") | |
| if cid: | |
| cases[cid] = case | |
| except json.JSONDecodeError: | |
| continue | |
| except Exception as e: | |
| logger.error(f"Failed to load cases: {e}") | |
| return cases | |
| def _save_case(case: Dict[str, Any]) -> None: | |
| """Append case snapshot to JSONL file.""" | |
| try: | |
| with open(CASES_PATH, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(case) + "\n") | |
| except Exception as e: | |
| logger.error(f"Failed to save case: {e}") | |
| def create_case( | |
| name: str, | |
| description: str = "", | |
| tags: List[str] = None, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Create a new investigation case. | |
| Returns the full case dict including generated case_id. | |
| """ | |
| if not name or not name.strip(): | |
| return {"error": "Case name is required."} | |
| case = { | |
| "case_id": str(uuid.uuid4()), | |
| "name": name.strip()[:200], | |
| "description": description.strip()[:2000], | |
| "tags": [t.strip() for t in (tags or []) if t.strip()][:20], | |
| "status": "open", | |
| "created_at": _now(), | |
| "updated_at": _now(), | |
| "evidence": [], | |
| } | |
| _save_case(case) | |
| logger.info(f"Case created: {case['case_id']} — {case['name']}") | |
| return case | |
| def add_evidence( | |
| case_id: str, | |
| evidence_id: str, | |
| filename: str, | |
| ai_probability: float, | |
| classification: str, | |
| notes: str = "", | |
| tags: List[str] = None, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Add an evidence item to an existing case. | |
| Args: | |
| case_id: Target case UUID | |
| evidence_id: UUID from forensic report | |
| filename: Original filename | |
| ai_probability: AI detection score 0.0-1.0 | |
| classification: e.g. likely_ai_generated | |
| notes: Free-text investigator notes | |
| tags: Optional evidence-level tags | |
| Returns: | |
| Updated case dict, or error dict. | |
| """ | |
| cases = _load_all_cases() | |
| if case_id not in cases: | |
| return {"error": f"Case not found: {case_id}"} | |
| case = cases[case_id] | |
| if case["status"] == "closed": | |
| return {"error": "Cannot add evidence to a closed case."} | |
| entry = { | |
| "evidence_id": evidence_id, | |
| "filename": filename, | |
| "ai_probability": round(float(ai_probability), 4), | |
| "classification": classification, | |
| "notes": notes.strip()[:1000], | |
| "tags": [t.strip() for t in (tags or []) if t.strip()][:10], | |
| "added_at": _now(), | |
| } | |
| case["evidence"].append(entry) | |
| case["updated_at"] = _now() | |
| _save_case(case) | |
| logger.info( | |
| f"Evidence added to case {case_id}: " | |
| f"{evidence_id} ({classification}, p={ai_probability:.3f})" | |
| ) | |
| return case | |
| def get_case(case_id: str) -> Dict[str, Any]: | |
| """Retrieve a case by ID.""" | |
| cases = _load_all_cases() | |
| if case_id not in cases: | |
| return {"error": f"Case not found: {case_id}"} | |
| return cases[case_id] | |
| def list_cases( | |
| status: Optional[str] = None, | |
| limit: int = 50, | |
| ) -> List[Dict[str, Any]]: | |
| """ | |
| List cases, optionally filtered by status. | |
| Returns cases sorted by updated_at descending. | |
| """ | |
| cases = _load_all_cases() | |
| result = list(cases.values()) | |
| if status and status in _VALID_STATUSES: | |
| result = [c for c in result if c["status"] == status] | |
| result.sort(key=lambda c: c.get("updated_at", ""), reverse=True) | |
| return result[:limit] | |
| def update_status(case_id: str, new_status: str) -> Dict[str, Any]: | |
| """Update case status. Valid: open, closed, archived.""" | |
| if new_status not in _VALID_STATUSES: | |
| return {"error": f"Invalid status. Must be one of: {_VALID_STATUSES}"} | |
| cases = _load_all_cases() | |
| if case_id not in cases: | |
| return {"error": f"Case not found: {case_id}"} | |
| case = cases[case_id] | |
| case["status"] = new_status | |
| case["updated_at"] = _now() | |
| _save_case(case) | |
| logger.info(f"Case {case_id} status changed to {new_status}") | |
| return case | |
| def search_cases(query: str, limit: int = 20) -> List[Dict[str, Any]]: | |
| """ | |
| Full-text search across case name, description, and tags. | |
| Case-insensitive substring match. | |
| """ | |
| if not query or not query.strip(): | |
| return list_cases(limit=limit) | |
| q = query.strip().lower() | |
| cases = _load_all_cases() | |
| result = [] | |
| for case in cases.values(): | |
| searchable = " ".join([ | |
| case.get("name", ""), | |
| case.get("description", ""), | |
| " ".join(case.get("tags", [])), | |
| " ".join( | |
| e.get("filename", "") + " " + e.get("notes", "") | |
| for e in case.get("evidence", []) | |
| ), | |
| ]).lower() | |
| if q in searchable: | |
| result.append(case) | |
| result.sort(key=lambda c: c.get("updated_at", ""), reverse=True) | |
| return result[:limit] | |
| def delete_case(case_id: str) -> Dict[str, Any]: | |
| """ | |
| Soft-delete: sets status to archived and saves tombstone. | |
| Cases are never physically removed (append-only log). | |
| """ | |
| return update_status(case_id, "archived") | |
| def get_case_summary(case_id: str) -> Dict[str, Any]: | |
| """Return lightweight case summary with aggregate evidence stats.""" | |
| case = get_case(case_id) | |
| if "error" in case: | |
| return case | |
| evidence = case.get("evidence", []) | |
| ai_probs = [e["ai_probability"] for e in evidence] | |
| ai_detected = sum(1 for e in evidence if "ai_generated" in e.get("classification", "")) | |
| return { | |
| "case_id": case["case_id"], | |
| "name": case["name"], | |
| "status": case["status"], | |
| "created_at": case["created_at"], | |
| "updated_at": case["updated_at"], | |
| "evidence_count": len(evidence), | |
| "ai_detected": ai_detected, | |
| "mean_ai_prob": round(sum(ai_probs) / len(ai_probs), 4) if ai_probs else 0.0, | |
| "tags": case.get("tags", []), | |
| } | |