Spaces:
Running
Running
Eric Putnam
Claude Opus 5 (1M context)
feat: deploy simpledocreview as a browser-only static space
3d968d9 Download app/app.py from Hoctar77/SimpleDocReview: direct link, hf CLI and curl.
- Browser
- Download file 45.7 kB
-
https://huggingface.co/spaces/Hoctar77/SimpleDocReview/resolve/main/app/app.py
- Command line
-
hf download hf://spaces/Hoctar77/SimpleDocReview/app/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Hoctar77/SimpleDocReview/resolve/main/app/app.py
45.7 kB
| from __future__ import annotations | |
| import html | |
| import json | |
| import mimetypes | |
| import re | |
| import shutil | |
| import uuid | |
| import zipfile | |
| from datetime import datetime, timezone | |
| from email.parser import BytesParser | |
| from email.policy import default as email_policy | |
| from http import HTTPStatus | |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| from io import BytesIO | |
| from pathlib import Path | |
| from urllib.parse import unquote, urlparse | |
| ROOT = Path(__file__).resolve().parent | |
| STATIC_DIR = ROOT / "static" | |
| DATA_DIR = ROOT / "data" | |
| UPLOAD_DIR = ROOT / "uploads" | |
| DB_PATH = DATA_DIR / "reviews.json" | |
| MAX_UPLOAD_BYTES = 5 * 1024 * 1024 | |
| ALLOWED_EXTENSIONS = {".txt", ".md", ".docx"} | |
| CONTACT_FIELDS = ("email", "slack", "teams") | |
| STATUS_LABELS = { | |
| "uploaded": "Uploaded", | |
| "in_review": "In review", | |
| "changes_requested": "Changes requested", | |
| "approved": "Approved", | |
| } | |
| ASSIGNMENT_LABELS = { | |
| "pending": "Pending", | |
| "commented": "Commented", | |
| "changes_requested": "Changes requested", | |
| "approved": "Approved", | |
| } | |
| def utc_now() -> str: | |
| return datetime.now(timezone.utc).isoformat(timespec="seconds") | |
| def ensure_storage() -> None: | |
| DATA_DIR.mkdir(exist_ok=True) | |
| UPLOAD_DIR.mkdir(exist_ok=True) | |
| if not DB_PATH.exists(): | |
| save_db({"documents": [], "reviewers": [], "notifications": []}) | |
| def load_db() -> dict: | |
| ensure_storage() | |
| try: | |
| with DB_PATH.open("r", encoding="utf-8") as handle: | |
| data = json.load(handle) | |
| except json.JSONDecodeError: | |
| data = {"documents": [], "reviewers": [], "notifications": []} | |
| if migrate_db(data): | |
| save_db(data) | |
| return data | |
| def save_db(data: dict) -> None: | |
| DATA_DIR.mkdir(exist_ok=True) | |
| tmp_path = DB_PATH.with_suffix(".json.tmp") | |
| with tmp_path.open("w", encoding="utf-8") as handle: | |
| json.dump(data, handle, indent=2) | |
| tmp_path.replace(DB_PATH) | |
| def clean_text(value: object, fallback: str = "", max_length: int = 240) -> str: | |
| text = str(value or "").strip() | |
| return (text or fallback)[:max_length] | |
| def empty_contact() -> dict: | |
| return {"email": "", "slack": "", "teams": ""} | |
| def normalize_contact(payload: dict | None) -> dict: | |
| payload = payload or {} | |
| return {field: clean_text(payload.get(field), max_length=240) for field in CONTACT_FIELDS} | |
| def owner_from_payload(payload: dict | None) -> dict: | |
| payload = payload or {} | |
| owner = {"name": clean_text(payload.get("name") or payload.get("owner_name"), "Owner", 120)} | |
| owner.update(normalize_contact(payload)) | |
| return owner | |
| def migrate_db(data: dict) -> bool: | |
| changed = False | |
| if not isinstance(data.get("documents"), list): | |
| data["documents"] = [] | |
| changed = True | |
| if not isinstance(data.get("reviewers"), list): | |
| data["reviewers"] = [] | |
| changed = True | |
| if not isinstance(data.get("notifications"), list): | |
| data["notifications"] = [] | |
| changed = True | |
| for reviewer in data["reviewers"]: | |
| changed = migrate_reviewer(reviewer) or changed | |
| for notification in data["notifications"]: | |
| changed = migrate_notification(notification) or changed | |
| for document in data["documents"]: | |
| changed = migrate_document(document) or changed | |
| return changed | |
| def migrate_reviewer(reviewer: dict) -> bool: | |
| changed = False | |
| now = utc_now() | |
| defaults = { | |
| "id": uuid.uuid4().hex, | |
| "name": "Reviewer", | |
| "created_at": now, | |
| "updated_at": now, | |
| } | |
| for key, value in defaults.items(): | |
| if not reviewer.get(key): | |
| reviewer[key] = value | |
| changed = True | |
| for field in CONTACT_FIELDS: | |
| if field not in reviewer: | |
| reviewer[field] = "" | |
| changed = True | |
| return changed | |
| def migrate_notification(notification: dict) -> bool: | |
| changed = False | |
| defaults = { | |
| "id": uuid.uuid4().hex, | |
| "recipient_type": "owner", | |
| "recipient_name": "Owner", | |
| "recipient_contact": empty_contact(), | |
| "document_id": "", | |
| "document_title": "", | |
| "event": "notice", | |
| "message": "", | |
| "created_at": utc_now(), | |
| "read_at": None, | |
| } | |
| for key, value in defaults.items(): | |
| if key not in notification: | |
| notification[key] = value | |
| changed = True | |
| if not isinstance(notification.get("recipient_contact"), dict): | |
| notification["recipient_contact"] = empty_contact() | |
| changed = True | |
| else: | |
| for field in CONTACT_FIELDS: | |
| if field not in notification["recipient_contact"]: | |
| notification["recipient_contact"][field] = "" | |
| changed = True | |
| return changed | |
| def migrate_document(record: dict) -> bool: | |
| changed = False | |
| now = utc_now() | |
| defaults = { | |
| "id": uuid.uuid4().hex, | |
| "filename": "document", | |
| "extension": ".txt", | |
| "status": "uploaded", | |
| "created_at": now, | |
| "updated_at": now, | |
| "reviewed_at": None, | |
| "approved_at": None, | |
| "word_count": 0, | |
| "findings": [], | |
| "comments": [], | |
| } | |
| for key, value in defaults.items(): | |
| if key not in record: | |
| record[key] = value | |
| changed = True | |
| if not isinstance(record.get("owner"), dict): | |
| record["owner"] = owner_from_payload({}) | |
| changed = True | |
| else: | |
| owner = owner_from_payload(record["owner"]) | |
| if owner != record["owner"]: | |
| record["owner"] = owner | |
| changed = True | |
| if not isinstance(record.get("assignments"), list): | |
| record["assignments"] = [] | |
| changed = True | |
| for assignment in record["assignments"]: | |
| changed = migrate_assignment(assignment) or changed | |
| if not isinstance(record.get("activity"), list): | |
| record["activity"] = build_legacy_activity(record) | |
| changed = True | |
| elif not record["activity"]: | |
| record["activity"] = build_legacy_activity(record) | |
| changed = True | |
| return changed | |
| def migrate_assignment(assignment: dict) -> bool: | |
| changed = False | |
| now = utc_now() | |
| defaults = { | |
| "id": uuid.uuid4().hex, | |
| "reviewer_id": "", | |
| "reviewer_name": "Reviewer", | |
| "reviewer_contact": empty_contact(), | |
| "status": "pending", | |
| "status_label": "Pending", | |
| "assigned_at": now, | |
| "updated_at": now, | |
| "approved_at": None, | |
| "approval_name": "", | |
| "approval_initials": "", | |
| "comments": [], | |
| } | |
| for key, value in defaults.items(): | |
| if key not in assignment: | |
| assignment[key] = value | |
| changed = True | |
| if assignment["status_label"] != ASSIGNMENT_LABELS.get(assignment["status"], assignment["status"]): | |
| assignment["status_label"] = ASSIGNMENT_LABELS.get(assignment["status"], assignment["status"]) | |
| changed = True | |
| if not isinstance(assignment.get("reviewer_contact"), dict): | |
| assignment["reviewer_contact"] = empty_contact() | |
| changed = True | |
| else: | |
| for field in CONTACT_FIELDS: | |
| if field not in assignment["reviewer_contact"]: | |
| assignment["reviewer_contact"][field] = "" | |
| changed = True | |
| if not isinstance(assignment.get("comments"), list): | |
| assignment["comments"] = [] | |
| changed = True | |
| return changed | |
| def build_legacy_activity(record: dict) -> list[dict]: | |
| activity = [ | |
| { | |
| "id": uuid.uuid4().hex, | |
| "type": "uploaded", | |
| "actor": record.get("owner", {}).get("name", "Owner"), | |
| "message": f"Uploaded {record.get('filename', 'document')}.", | |
| "created_at": record.get("created_at") or utc_now(), | |
| } | |
| ] | |
| if record.get("reviewed_at"): | |
| activity.append( | |
| { | |
| "id": uuid.uuid4().hex, | |
| "type": "review_run", | |
| "actor": "System", | |
| "message": "Ran the local rubric review.", | |
| "created_at": record["reviewed_at"], | |
| } | |
| ) | |
| for comment in record.get("comments", []): | |
| activity.append( | |
| { | |
| "id": uuid.uuid4().hex, | |
| "type": "comment_added", | |
| "actor": comment.get("author", "Reviewer"), | |
| "message": f"Commented: {comment.get('body', '')[:120]}", | |
| "created_at": comment.get("created_at") or utc_now(), | |
| } | |
| ) | |
| if record.get("approved_at"): | |
| activity.append( | |
| { | |
| "id": uuid.uuid4().hex, | |
| "type": "approved", | |
| "actor": "Reviewer", | |
| "message": "Approved the document.", | |
| "created_at": record["approved_at"], | |
| } | |
| ) | |
| return sorted(activity, key=lambda item: item["created_at"]) | |
| def sanitize_filename(filename: str) -> str: | |
| name = Path(filename or "document").name.strip() | |
| stem = Path(name).stem or "document" | |
| suffix = Path(name).suffix.lower() | |
| clean_stem = re.sub(r"[^A-Za-z0-9._ -]+", "-", stem).strip(" .-_") | |
| return f"{clean_stem or 'document'}{suffix}" | |
| def decode_text_bytes(content: bytes) -> str: | |
| for encoding in ("utf-8-sig", "utf-16", "cp1252"): | |
| try: | |
| return content.decode(encoding) | |
| except UnicodeDecodeError: | |
| continue | |
| return content.decode("utf-8", errors="replace") | |
| def extract_docx_text(content: bytes) -> str: | |
| with zipfile.ZipFile(BytesIO(content)) as archive: | |
| try: | |
| xml_bytes = archive.read("word/document.xml") | |
| except KeyError as exc: | |
| raise ValueError("The DOCX file does not contain document text.") from exc | |
| xml_text = xml_bytes.decode("utf-8", errors="replace") | |
| paragraphs = re.findall(r"<w:p\b[^>]*>(.*?)</w:p>", xml_text, flags=re.DOTALL) | |
| extracted: list[str] = [] | |
| for paragraph in paragraphs: | |
| text_runs = re.findall(r"<w:t\b[^>]*>(.*?)</w:t>", paragraph, flags=re.DOTALL) | |
| if text_runs: | |
| extracted.append("".join(html.unescape(run) for run in text_runs)) | |
| return "\n".join(line.strip() for line in extracted if line.strip()) | |
| def extract_text(filename: str, content: bytes) -> str: | |
| extension = Path(filename).suffix.lower() | |
| if extension in {".txt", ".md"}: | |
| return decode_text_bytes(content).strip() | |
| if extension == ".docx": | |
| return extract_docx_text(content).strip() | |
| raise ValueError("Unsupported document type.") | |
| def snippet_for(text: str, pattern: re.Pattern[str]) -> str: | |
| match = pattern.search(text) | |
| if not match: | |
| return "" | |
| start = max(0, match.start() - 70) | |
| end = min(len(text), match.end() + 70) | |
| return re.sub(r"\s+", " ", text[start:end]).strip() | |
| def has_section(text: str, terms: tuple[str, ...]) -> bool: | |
| for term in terms: | |
| heading_pattern = re.compile(rf"(?im)^\s*(?:#+\s*)?{re.escape(term)}\b") | |
| if heading_pattern.search(text): | |
| return True | |
| label_pattern = re.compile(rf"(?i)\b{re.escape(term)}\s*:") | |
| if label_pattern.search(text): | |
| return True | |
| return False | |
| def analyze_document(text: str) -> dict: | |
| findings: list[dict] = [] | |
| normalized_text = text.strip() | |
| word_count = len(re.findall(r"\b[\w'-]+\b", normalized_text)) | |
| section_checks = [ | |
| ("purpose", ("purpose", "objective"), "Purpose or objective"), | |
| ("scope", ("scope", "applicability"), "Scope"), | |
| ("owner", ("owner", "document owner", "responsible party"), "Owner"), | |
| ("date", ("date", "effective date", "review date"), "Date or review date"), | |
| ("risk", ("risk", "risks", "risk assessment"), "Risk section"), | |
| ("approval", ("approval", "approved by", "signature", "sign-off"), "Approval or signature"), | |
| ] | |
| for key, terms, label in section_checks: | |
| if not has_section(normalized_text, terms): | |
| severity = "high" if key == "approval" else "medium" | |
| findings.append( | |
| { | |
| "id": f"missing-{key}", | |
| "severity": severity, | |
| "title": f"Missing {label.lower()}", | |
| "detail": f"Add a clear {label.lower()} section or label before approval.", | |
| "snippet": "", | |
| } | |
| ) | |
| placeholder_checks = [ | |
| ("todo", re.compile(r"(?i)\bTODO\b|\bTBD\b|\bTBA\b")), | |
| ("insert", re.compile(r"(?i)\[(?:insert|add|fill in)[^\]]*\]|<\s*(?:insert|add|fill in)[^>]*>")), | |
| ("blank", re.compile(r"_{4,}|\{\{[^}]+\}\}")), | |
| ] | |
| for key, pattern in placeholder_checks: | |
| if pattern.search(normalized_text): | |
| findings.append( | |
| { | |
| "id": f"placeholder-{key}", | |
| "severity": "high", | |
| "title": "Unresolved placeholder", | |
| "detail": "Replace template placeholders before the document can be considered final.", | |
| "snippet": snippet_for(normalized_text, pattern), | |
| } | |
| ) | |
| vague_language = [ | |
| "best effort", | |
| "as needed", | |
| "reasonable", | |
| "as appropriate", | |
| "when possible", | |
| "from time to time", | |
| ] | |
| for phrase in vague_language: | |
| pattern = re.compile(rf"(?i)\b{re.escape(phrase)}\b") | |
| if pattern.search(normalized_text): | |
| findings.append( | |
| { | |
| "id": f"vague-{phrase.replace(' ', '-')}", | |
| "severity": "medium", | |
| "title": f'Vague language: "{phrase}"', | |
| "detail": "Make the requirement measurable, accountable, or time-bound.", | |
| "snippet": snippet_for(normalized_text, pattern), | |
| } | |
| ) | |
| if word_count < 80: | |
| findings.append( | |
| { | |
| "id": "short-document", | |
| "severity": "low", | |
| "title": "Very short document", | |
| "detail": "Confirm the document has enough context for reviewers and approvers.", | |
| "snippet": "", | |
| } | |
| ) | |
| long_sentence_pattern = re.compile(r"[^.!?]{260,}[.!?]") | |
| if long_sentence_pattern.search(normalized_text): | |
| findings.append( | |
| { | |
| "id": "long-sentence", | |
| "severity": "low", | |
| "title": "Long sentence", | |
| "detail": "Break long sentences into shorter reviewable statements.", | |
| "snippet": snippet_for(normalized_text, long_sentence_pattern), | |
| } | |
| ) | |
| severity_order = {"high": 0, "medium": 1, "low": 2} | |
| findings.sort(key=lambda item: (severity_order[item["severity"]], item["title"])) | |
| counts = { | |
| "high": sum(1 for item in findings if item["severity"] == "high"), | |
| "medium": sum(1 for item in findings if item["severity"] == "medium"), | |
| "low": sum(1 for item in findings if item["severity"] == "low"), | |
| } | |
| return { | |
| "reviewed_at": utc_now(), | |
| "word_count": word_count, | |
| "findings": findings, | |
| "counts": counts, | |
| } | |
| def add_activity(record: dict, event_type: str, actor: str, message: str, timestamp: str | None = None) -> None: | |
| record.setdefault("activity", []).append( | |
| { | |
| "id": uuid.uuid4().hex, | |
| "type": event_type, | |
| "actor": clean_text(actor, "System", 120), | |
| "message": clean_text(message, max_length=500), | |
| "created_at": timestamp or utc_now(), | |
| } | |
| ) | |
| def assignment_counts(record: dict) -> dict: | |
| counts = {"pending": 0, "commented": 0, "changes_requested": 0, "approved": 0} | |
| for assignment in record.get("assignments", []): | |
| counts[assignment.get("status", "pending")] = counts.get(assignment.get("status", "pending"), 0) + 1 | |
| return counts | |
| def recalculate_document_status(record: dict) -> None: | |
| assignments = record.get("assignments", []) | |
| if not assignments: | |
| return | |
| statuses = {assignment.get("status", "pending") for assignment in assignments} | |
| if "changes_requested" in statuses: | |
| record["status"] = "changes_requested" | |
| record["approved_at"] = None | |
| elif statuses == {"approved"}: | |
| record["status"] = "approved" | |
| record["approved_at"] = record.get("approved_at") or utc_now() | |
| else: | |
| record["status"] = "in_review" | |
| record["approved_at"] = None | |
| def public_assignment(assignment: dict) -> dict: | |
| return { | |
| "id": assignment["id"], | |
| "reviewer_id": assignment["reviewer_id"], | |
| "reviewer_name": assignment["reviewer_name"], | |
| "reviewer_contact": assignment.get("reviewer_contact", empty_contact()), | |
| "status": assignment["status"], | |
| "status_label": ASSIGNMENT_LABELS.get(assignment["status"], assignment["status"]), | |
| "assigned_at": assignment["assigned_at"], | |
| "updated_at": assignment["updated_at"], | |
| "approved_at": assignment.get("approved_at"), | |
| "approval_name": assignment.get("approval_name", ""), | |
| "approval_initials": assignment.get("approval_initials", ""), | |
| "comments": assignment.get("comments", []), | |
| } | |
| def public_document(record: dict, include_text: bool = False) -> dict: | |
| counts = assignment_counts(record) | |
| public = { | |
| "id": record["id"], | |
| "filename": record["filename"], | |
| "extension": record["extension"], | |
| "status": record["status"], | |
| "status_label": STATUS_LABELS.get(record["status"], record["status"]), | |
| "created_at": record["created_at"], | |
| "updated_at": record["updated_at"], | |
| "reviewed_at": record.get("reviewed_at"), | |
| "approved_at": record.get("approved_at"), | |
| "word_count": record.get("word_count", 0), | |
| "findings": record.get("findings", []), | |
| "comments": record.get("comments", []), | |
| "owner": record.get("owner", owner_from_payload({})), | |
| "assignments": [public_assignment(item) for item in record.get("assignments", [])], | |
| "assignment_counts": counts, | |
| "activity": sorted(record.get("activity", []), key=lambda item: item.get("created_at", ""), reverse=True), | |
| } | |
| if include_text: | |
| public["text"] = record.get("text", "") | |
| return public | |
| def public_reviewer(reviewer: dict) -> dict: | |
| return { | |
| "id": reviewer["id"], | |
| "name": reviewer["name"], | |
| "email": reviewer.get("email", ""), | |
| "slack": reviewer.get("slack", ""), | |
| "teams": reviewer.get("teams", ""), | |
| "created_at": reviewer["created_at"], | |
| "updated_at": reviewer["updated_at"], | |
| } | |
| def public_notification(notification: dict) -> dict: | |
| return { | |
| "id": notification["id"], | |
| "recipient_type": notification["recipient_type"], | |
| "recipient_name": notification["recipient_name"], | |
| "recipient_contact": notification.get("recipient_contact", empty_contact()), | |
| "document_id": notification["document_id"], | |
| "document_title": notification["document_title"], | |
| "event": notification["event"], | |
| "message": notification["message"], | |
| "created_at": notification["created_at"], | |
| "read_at": notification.get("read_at"), | |
| } | |
| def find_document(data: dict, document_id: str) -> dict | None: | |
| return next((doc for doc in data["documents"] if doc["id"] == document_id), None) | |
| def find_reviewer(data: dict, reviewer_id: str) -> dict | None: | |
| return next((reviewer for reviewer in data["reviewers"] if reviewer["id"] == reviewer_id), None) | |
| def find_assignment(record: dict, reviewer_id: str) -> dict | None: | |
| return next((item for item in record.get("assignments", []) if item["reviewer_id"] == reviewer_id), None) | |
| def contact_summary(contact: dict) -> str: | |
| parts = [contact.get("email", ""), contact.get("slack", ""), contact.get("teams", "")] | |
| return " | ".join(part for part in parts if part) | |
| def reviewer_payload(payload: dict, existing: dict | None = None) -> dict: | |
| reviewer = dict(existing or {}) | |
| reviewer["name"] = clean_text(payload.get("name"), max_length=120) | |
| if not reviewer["name"]: | |
| raise ValueError("Reviewer name is required.") | |
| reviewer.update(normalize_contact(payload)) | |
| return reviewer | |
| def build_assignment(reviewer: dict) -> dict: | |
| timestamp = utc_now() | |
| assignment = { | |
| "id": uuid.uuid4().hex, | |
| "reviewer_id": reviewer["id"], | |
| "reviewer_name": reviewer["name"], | |
| "reviewer_contact": normalize_contact(reviewer), | |
| "status": "pending", | |
| "status_label": "Pending", | |
| "assigned_at": timestamp, | |
| "updated_at": timestamp, | |
| "approved_at": None, | |
| "approval_name": "", | |
| "approval_initials": "", | |
| "comments": [], | |
| } | |
| migrate_assignment(assignment) | |
| return assignment | |
| def create_notification( | |
| data: dict, | |
| record: dict, | |
| recipient_type: str, | |
| recipient_name: str, | |
| recipient_contact: dict, | |
| event: str, | |
| message: str, | |
| ) -> dict: | |
| notification = { | |
| "id": uuid.uuid4().hex, | |
| "recipient_type": recipient_type, | |
| "recipient_name": clean_text(recipient_name, "Recipient", 120), | |
| "recipient_contact": normalize_contact(recipient_contact), | |
| "document_id": record["id"], | |
| "document_title": record["filename"], | |
| "event": event, | |
| "message": clean_text(message, max_length=1000), | |
| "created_at": utc_now(), | |
| "read_at": None, | |
| } | |
| data.setdefault("notifications", []).append(notification) | |
| add_activity( | |
| record, | |
| "notification", | |
| "System", | |
| f"Queued notification for {notification['recipient_name']}: {notification['message']}", | |
| notification["created_at"], | |
| ) | |
| return notification | |
| def parse_multipart_upload(content_type: str, body: bytes) -> tuple[str, bytes, dict]: | |
| if "multipart/form-data" not in content_type: | |
| raise ValueError("Upload must use multipart/form-data.") | |
| message_bytes = ( | |
| f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode("utf-8") | |
| + body | |
| ) | |
| message = BytesParser(policy=email_policy).parsebytes(message_bytes) | |
| if not message.is_multipart(): | |
| raise ValueError("Upload payload is not multipart.") | |
| filename = "" | |
| file_payload = b"" | |
| fields: dict[str, str] = {} | |
| for part in message.iter_parts(): | |
| part_name = part.get_param("name", header="content-disposition") | |
| part_filename = part.get_filename() | |
| payload = part.get_payload(decode=True) or b"" | |
| if part_filename: | |
| filename = sanitize_filename(part_filename) | |
| file_payload = payload | |
| elif part_name: | |
| fields[part_name] = decode_text_bytes(payload).strip() | |
| if not filename: | |
| raise ValueError("No file part was found in the upload.") | |
| return filename, file_payload, fields | |
| class ReviewHandler(BaseHTTPRequestHandler): | |
| server_version = "SimpleDocReview/2.0" | |
| def log_message(self, format: str, *args: object) -> None: | |
| print(f"{self.address_string()} - {format % args}") | |
| def send_json(self, payload: dict | list, status: int = 200) -> None: | |
| encoded = json.dumps(payload).encode("utf-8") | |
| self.send_response(status) | |
| self.send_header("Content-Type", "application/json; charset=utf-8") | |
| self.send_header("Content-Length", str(len(encoded))) | |
| self.end_headers() | |
| self.wfile.write(encoded) | |
| def send_error_json(self, message: str, status: int = 400) -> None: | |
| self.send_json({"error": message}, status) | |
| def read_json_body(self) -> dict: | |
| length = int(self.headers.get("Content-Length", "0") or "0") | |
| if length == 0: | |
| return {} | |
| raw = self.rfile.read(length) | |
| try: | |
| return json.loads(raw.decode("utf-8")) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError("Request body must be valid JSON.") from exc | |
| def path_parts(self) -> list[str]: | |
| parsed = urlparse(self.path) | |
| return [unquote(part) for part in parsed.path.strip("/").split("/") if part] | |
| def do_GET(self) -> None: | |
| parsed = urlparse(self.path) | |
| path = parsed.path | |
| parts = self.path_parts() | |
| if path == "/" or path == "/index.html": | |
| self.serve_static(STATIC_DIR / "index.html") | |
| return | |
| if path.startswith("/static/"): | |
| requested = STATIC_DIR / unquote(path.removeprefix("/static/")) | |
| self.serve_static(requested) | |
| return | |
| if parts == ["api", "documents"]: | |
| data = load_db() | |
| documents = [public_document(doc) for doc in data["documents"]] | |
| documents.sort(key=lambda item: item["updated_at"], reverse=True) | |
| self.send_json({"documents": documents}) | |
| return | |
| if parts == ["api", "reviewers"]: | |
| data = load_db() | |
| reviewers = [public_reviewer(reviewer) for reviewer in data["reviewers"]] | |
| reviewers.sort(key=lambda item: item["name"].lower()) | |
| self.send_json({"reviewers": reviewers}) | |
| return | |
| if parts == ["api", "notifications"]: | |
| data = load_db() | |
| notifications = [public_notification(item) for item in data["notifications"]] | |
| notifications.sort(key=lambda item: item["created_at"], reverse=True) | |
| unread_count = sum(1 for item in notifications if not item.get("read_at")) | |
| self.send_json({"notifications": notifications, "unread_count": unread_count}) | |
| return | |
| if len(parts) == 3 and parts[:2] == ["api", "documents"]: | |
| data = load_db() | |
| record = find_document(data, parts[2]) | |
| if not record: | |
| self.send_error_json("Document not found.", HTTPStatus.NOT_FOUND) | |
| return | |
| self.send_json(public_document(record, include_text=True)) | |
| return | |
| if len(parts) == 4 and parts[:2] == ["api", "documents"] and parts[3] == "download": | |
| data = load_db() | |
| record = find_document(data, parts[2]) | |
| if not record: | |
| self.send_error_json("Document not found.", HTTPStatus.NOT_FOUND) | |
| return | |
| self.serve_download(record) | |
| return | |
| self.send_error_json("Not found.", HTTPStatus.NOT_FOUND) | |
| def do_POST(self) -> None: | |
| parts = self.path_parts() | |
| try: | |
| if parts == ["api", "upload"]: | |
| self.handle_upload() | |
| return | |
| if parts == ["api", "reviewers"]: | |
| self.handle_create_reviewer() | |
| return | |
| if len(parts) == 4 and parts[:2] == ["api", "notifications"] and parts[3] == "read": | |
| self.handle_notification_read(parts[2]) | |
| return | |
| if len(parts) == 4 and parts[:2] == ["api", "documents"]: | |
| action = parts[3] | |
| if action == "review": | |
| self.handle_review(parts[2]) | |
| return | |
| if action == "comments": | |
| self.handle_comment(parts[2]) | |
| return | |
| if action == "approve": | |
| self.handle_status(parts[2], "approved") | |
| return | |
| if action == "request-changes": | |
| self.handle_status(parts[2], "changes_requested") | |
| return | |
| if action == "assign-reviewers": | |
| self.handle_assign_reviewers(parts[2]) | |
| return | |
| if action == "reviewer-comment": | |
| self.handle_reviewer_comment(parts[2]) | |
| return | |
| if action == "reviewer-approve": | |
| self.handle_reviewer_approve(parts[2]) | |
| return | |
| if action == "reviewer-request-changes": | |
| self.handle_reviewer_request_changes(parts[2]) | |
| return | |
| except ValueError as exc: | |
| self.send_error_json(str(exc), HTTPStatus.BAD_REQUEST) | |
| return | |
| except zipfile.BadZipFile: | |
| self.send_error_json("The DOCX file appears to be invalid.", HTTPStatus.BAD_REQUEST) | |
| return | |
| self.send_error_json("Not found.", HTTPStatus.NOT_FOUND) | |
| def do_PUT(self) -> None: | |
| parts = self.path_parts() | |
| try: | |
| if len(parts) == 3 and parts[:2] == ["api", "reviewers"]: | |
| self.handle_update_reviewer(parts[2]) | |
| return | |
| except ValueError as exc: | |
| self.send_error_json(str(exc), HTTPStatus.BAD_REQUEST) | |
| return | |
| self.send_error_json("Not found.", HTTPStatus.NOT_FOUND) | |
| def do_DELETE(self) -> None: | |
| parts = self.path_parts() | |
| if len(parts) == 3 and parts[:2] == ["api", "reviewers"]: | |
| self.handle_delete_reviewer(parts[2]) | |
| return | |
| self.send_error_json("Not found.", HTTPStatus.NOT_FOUND) | |
| def serve_static(self, requested: Path) -> None: | |
| try: | |
| resolved = requested.resolve() | |
| static_root = STATIC_DIR.resolve() | |
| if static_root not in (resolved, *resolved.parents): | |
| raise FileNotFoundError | |
| if not resolved.is_file(): | |
| raise FileNotFoundError | |
| except FileNotFoundError: | |
| self.send_error_json("Static file not found.", HTTPStatus.NOT_FOUND) | |
| return | |
| content_type = mimetypes.guess_type(resolved.name)[0] or "application/octet-stream" | |
| content = resolved.read_bytes() | |
| self.send_response(HTTPStatus.OK) | |
| self.send_header("Content-Type", content_type) | |
| self.send_header("Content-Length", str(len(content))) | |
| self.end_headers() | |
| self.wfile.write(content) | |
| def serve_download(self, record: dict) -> None: | |
| file_path = UPLOAD_DIR / record["stored_name"] | |
| if not file_path.exists(): | |
| self.send_error_json("Original upload is missing.", HTTPStatus.NOT_FOUND) | |
| return | |
| content_type = mimetypes.guess_type(record["filename"])[0] or "application/octet-stream" | |
| self.send_response(HTTPStatus.OK) | |
| self.send_header("Content-Type", content_type) | |
| self.send_header("Content-Disposition", f'attachment; filename="{record["filename"]}"') | |
| self.send_header("Content-Length", str(file_path.stat().st_size)) | |
| self.end_headers() | |
| with file_path.open("rb") as handle: | |
| shutil.copyfileobj(handle, self.wfile) | |
| def handle_upload(self) -> None: | |
| length = int(self.headers.get("Content-Length", "0") or "0") | |
| if length <= 0: | |
| raise ValueError("Upload cannot be empty.") | |
| if length > MAX_UPLOAD_BYTES: | |
| raise ValueError("Upload is too large. Maximum size is 5 MB.") | |
| filename, content, fields = parse_multipart_upload(self.headers.get("Content-Type", ""), self.rfile.read(length)) | |
| extension = Path(filename).suffix.lower() | |
| if extension not in ALLOWED_EXTENSIONS: | |
| self.send_error_json("Unsupported file type. Upload TXT, MD, or DOCX.", HTTPStatus.UNSUPPORTED_MEDIA_TYPE) | |
| return | |
| if not content: | |
| raise ValueError("Uploaded file is empty.") | |
| text = extract_text(filename, content) | |
| if not text: | |
| raise ValueError("No readable text was found in the document.") | |
| document_id = uuid.uuid4().hex | |
| stored_name = f"{document_id}{extension}" | |
| (UPLOAD_DIR / stored_name).write_bytes(content) | |
| timestamp = utc_now() | |
| owner = owner_from_payload( | |
| { | |
| "name": fields.get("owner_name") or fields.get("owner"), | |
| "email": fields.get("owner_email"), | |
| "slack": fields.get("owner_slack"), | |
| "teams": fields.get("owner_teams"), | |
| } | |
| ) | |
| record = { | |
| "id": document_id, | |
| "filename": filename, | |
| "stored_name": stored_name, | |
| "extension": extension, | |
| "status": "uploaded", | |
| "created_at": timestamp, | |
| "updated_at": timestamp, | |
| "reviewed_at": None, | |
| "approved_at": None, | |
| "word_count": len(re.findall(r"\b[\w'-]+\b", text)), | |
| "text": text, | |
| "findings": [], | |
| "comments": [], | |
| "owner": owner, | |
| "assignments": [], | |
| "activity": [], | |
| } | |
| add_activity(record, "uploaded", owner["name"], f"Uploaded {filename}.", timestamp) | |
| data = load_db() | |
| data["documents"].append(record) | |
| save_db(data) | |
| self.send_json(public_document(record, include_text=True), HTTPStatus.CREATED) | |
| def handle_review(self, document_id: str) -> None: | |
| data = load_db() | |
| record = find_document(data, document_id) | |
| if not record: | |
| self.send_error_json("Document not found.", HTTPStatus.NOT_FOUND) | |
| return | |
| result = analyze_document(record.get("text", "")) | |
| record["findings"] = result["findings"] | |
| record["reviewed_at"] = result["reviewed_at"] | |
| record["word_count"] = result["word_count"] | |
| if record["status"] != "approved": | |
| record["status"] = "in_review" | |
| record["updated_at"] = utc_now() | |
| add_activity(record, "review_run", "System", "Ran the local rubric review.", result["reviewed_at"]) | |
| save_db(data) | |
| self.send_json(public_document(record, include_text=True)) | |
| def handle_comment(self, document_id: str) -> None: | |
| payload = self.read_json_body() | |
| body = clean_text(payload.get("body"), max_length=2000) | |
| author = clean_text(payload.get("author"), "Reviewer", 80) | |
| if not body: | |
| raise ValueError("Comment body is required.") | |
| data = load_db() | |
| record = find_document(data, document_id) | |
| if not record: | |
| self.send_error_json("Document not found.", HTTPStatus.NOT_FOUND) | |
| return | |
| timestamp = utc_now() | |
| record.setdefault("comments", []).append( | |
| { | |
| "id": uuid.uuid4().hex, | |
| "author": author, | |
| "body": body, | |
| "created_at": timestamp, | |
| } | |
| ) | |
| record["updated_at"] = timestamp | |
| add_activity(record, "comment_added", author, f"Commented: {body[:120]}", timestamp) | |
| save_db(data) | |
| self.send_json(public_document(record, include_text=True)) | |
| def handle_status(self, document_id: str, status: str) -> None: | |
| data = load_db() | |
| record = find_document(data, document_id) | |
| if not record: | |
| self.send_error_json("Document not found.", HTTPStatus.NOT_FOUND) | |
| return | |
| record["status"] = status | |
| record["updated_at"] = utc_now() | |
| if status == "approved": | |
| record["approved_at"] = utc_now() | |
| add_activity(record, "approved", record.get("owner", {}).get("name", "Owner"), "Owner approved the document.") | |
| else: | |
| record["approved_at"] = None | |
| add_activity(record, "changes_requested", record.get("owner", {}).get("name", "Owner"), "Owner requested changes.") | |
| save_db(data) | |
| self.send_json(public_document(record, include_text=True)) | |
| def handle_create_reviewer(self) -> None: | |
| payload = self.read_json_body() | |
| timestamp = utc_now() | |
| reviewer = reviewer_payload(payload) | |
| reviewer.update({"id": uuid.uuid4().hex, "created_at": timestamp, "updated_at": timestamp}) | |
| data = load_db() | |
| data["reviewers"].append(reviewer) | |
| save_db(data) | |
| self.send_json(public_reviewer(reviewer), HTTPStatus.CREATED) | |
| def handle_update_reviewer(self, reviewer_id: str) -> None: | |
| data = load_db() | |
| reviewer = find_reviewer(data, reviewer_id) | |
| if not reviewer: | |
| self.send_error_json("Reviewer not found.", HTTPStatus.NOT_FOUND) | |
| return | |
| updated = reviewer_payload(self.read_json_body(), existing=reviewer) | |
| reviewer.update(updated) | |
| reviewer["updated_at"] = utc_now() | |
| save_db(data) | |
| self.send_json(public_reviewer(reviewer)) | |
| def handle_delete_reviewer(self, reviewer_id: str) -> None: | |
| data = load_db() | |
| original_count = len(data["reviewers"]) | |
| data["reviewers"] = [reviewer for reviewer in data["reviewers"] if reviewer["id"] != reviewer_id] | |
| if len(data["reviewers"]) == original_count: | |
| self.send_error_json("Reviewer not found.", HTTPStatus.NOT_FOUND) | |
| return | |
| save_db(data) | |
| self.send_json({"ok": True}) | |
| def handle_assign_reviewers(self, document_id: str) -> None: | |
| payload = self.read_json_body() | |
| reviewer_ids = payload.get("reviewer_ids", []) | |
| if not isinstance(reviewer_ids, list): | |
| raise ValueError("reviewer_ids must be a list.") | |
| reviewer_ids = list(dict.fromkeys(str(item) for item in reviewer_ids if str(item).strip())) | |
| data = load_db() | |
| record = find_document(data, document_id) | |
| if not record: | |
| self.send_error_json("Document not found.", HTTPStatus.NOT_FOUND) | |
| return | |
| reviewers_by_id = {reviewer["id"]: reviewer for reviewer in data["reviewers"]} | |
| missing = [reviewer_id for reviewer_id in reviewer_ids if reviewer_id not in reviewers_by_id] | |
| if missing: | |
| raise ValueError("One or more selected reviewers no longer exists.") | |
| existing_by_id = {assignment["reviewer_id"]: assignment for assignment in record.get("assignments", [])} | |
| assignments = [] | |
| new_assignments = [] | |
| for reviewer_id in reviewer_ids: | |
| assignment = existing_by_id.get(reviewer_id) | |
| if assignment: | |
| assignments.append(assignment) | |
| else: | |
| assignment = build_assignment(reviewers_by_id[reviewer_id]) | |
| assignments.append(assignment) | |
| new_assignments.append(assignment) | |
| record["assignments"] = assignments | |
| timestamp = utc_now() | |
| record["updated_at"] = timestamp | |
| if reviewer_ids: | |
| record["status"] = "in_review" | |
| names = ", ".join(assignment["reviewer_name"] for assignment in assignments) | |
| add_activity(record, "reviewers_assigned", record["owner"]["name"], f"Assigned reviewers: {names}.", timestamp) | |
| else: | |
| record["status"] = "in_review" if record.get("reviewed_at") else "uploaded" | |
| record["approved_at"] = None | |
| add_activity(record, "reviewers_assigned", record["owner"]["name"], "Cleared reviewer assignments.", timestamp) | |
| for assignment in new_assignments: | |
| message = ( | |
| f"Please review {record['filename']} in SimpleDocReview. " | |
| f"Open the local app, select the document, and respond as {assignment['reviewer_name']}." | |
| ) | |
| create_notification( | |
| data, | |
| record, | |
| "reviewer", | |
| assignment["reviewer_name"], | |
| assignment["reviewer_contact"], | |
| "review_assigned", | |
| message, | |
| ) | |
| recalculate_document_status(record) | |
| save_db(data) | |
| self.send_json(public_document(record, include_text=True)) | |
| def handle_reviewer_comment(self, document_id: str) -> None: | |
| payload = self.read_json_body() | |
| data, record, assignment = self.require_assignment(document_id, payload) | |
| body = clean_text(payload.get("body"), max_length=2000) | |
| signature = clean_text(payload.get("signature"), assignment["reviewer_name"], 120) | |
| if not body: | |
| raise ValueError("Comment body is required.") | |
| timestamp = utc_now() | |
| comment = { | |
| "id": uuid.uuid4().hex, | |
| "author": signature, | |
| "reviewer_id": assignment["reviewer_id"], | |
| "body": body, | |
| "created_at": timestamp, | |
| } | |
| assignment.setdefault("comments", []).append(comment) | |
| if assignment["status"] not in {"approved", "changes_requested"}: | |
| assignment["status"] = "commented" | |
| assignment["updated_at"] = timestamp | |
| migrate_assignment(assignment) | |
| record.setdefault("comments", []).append(comment) | |
| record["updated_at"] = timestamp | |
| add_activity(record, "reviewer_comment", signature, f"Commented: {body[:120]}", timestamp) | |
| create_notification( | |
| data, | |
| record, | |
| "owner", | |
| record["owner"]["name"], | |
| record["owner"], | |
| "reviewer_comment", | |
| f"{signature} commented on {record['filename']}: {body[:180]}", | |
| ) | |
| recalculate_document_status(record) | |
| save_db(data) | |
| self.send_json(public_document(record, include_text=True)) | |
| def handle_reviewer_approve(self, document_id: str) -> None: | |
| payload = self.read_json_body() | |
| data, record, assignment = self.require_assignment(document_id, payload) | |
| signature = clean_text(payload.get("signature"), max_length=120) | |
| if not signature: | |
| raise ValueError("Approval name or initials are required.") | |
| was_final = record.get("status") == "approved" | |
| timestamp = utc_now() | |
| assignment["status"] = "approved" | |
| assignment["approval_name"] = signature | |
| assignment["approval_initials"] = clean_text(payload.get("initials") or signature, max_length=24) | |
| assignment["approved_at"] = timestamp | |
| assignment["updated_at"] = timestamp | |
| migrate_assignment(assignment) | |
| record["updated_at"] = timestamp | |
| add_activity(record, "reviewer_approved", signature, f"Approved as {signature}.", timestamp) | |
| recalculate_document_status(record) | |
| if record.get("status") == "approved" and not was_final: | |
| add_activity(record, "approved", "System", "All assigned reviewers approved the document.", record["approved_at"]) | |
| create_notification( | |
| data, | |
| record, | |
| "owner", | |
| record["owner"]["name"], | |
| record["owner"], | |
| "reviewer_approved", | |
| f"{signature} approved {record['filename']}.", | |
| ) | |
| save_db(data) | |
| self.send_json(public_document(record, include_text=True)) | |
| def handle_reviewer_request_changes(self, document_id: str) -> None: | |
| payload = self.read_json_body() | |
| data, record, assignment = self.require_assignment(document_id, payload) | |
| body = clean_text(payload.get("body"), max_length=2000) | |
| signature = clean_text(payload.get("signature"), assignment["reviewer_name"], 120) | |
| if not body: | |
| raise ValueError("A change request note is required.") | |
| timestamp = utc_now() | |
| assignment["status"] = "changes_requested" | |
| assignment["approved_at"] = None | |
| assignment["approval_name"] = "" | |
| assignment["approval_initials"] = "" | |
| assignment["updated_at"] = timestamp | |
| comment = { | |
| "id": uuid.uuid4().hex, | |
| "author": signature, | |
| "reviewer_id": assignment["reviewer_id"], | |
| "body": body, | |
| "created_at": timestamp, | |
| } | |
| assignment.setdefault("comments", []).append(comment) | |
| migrate_assignment(assignment) | |
| record.setdefault("comments", []).append(comment) | |
| record["updated_at"] = timestamp | |
| recalculate_document_status(record) | |
| add_activity(record, "reviewer_changes_requested", signature, f"Requested changes: {body[:120]}", timestamp) | |
| create_notification( | |
| data, | |
| record, | |
| "owner", | |
| record["owner"]["name"], | |
| record["owner"], | |
| "reviewer_changes_requested", | |
| f"{signature} requested changes on {record['filename']}: {body[:180]}", | |
| ) | |
| save_db(data) | |
| self.send_json(public_document(record, include_text=True)) | |
| def handle_notification_read(self, notification_id: str) -> None: | |
| data = load_db() | |
| notification = next((item for item in data["notifications"] if item["id"] == notification_id), None) | |
| if not notification: | |
| self.send_error_json("Notification not found.", HTTPStatus.NOT_FOUND) | |
| return | |
| notification["read_at"] = notification.get("read_at") or utc_now() | |
| save_db(data) | |
| self.send_json(public_notification(notification)) | |
| def require_assignment(self, document_id: str, payload: dict) -> tuple[dict, dict, dict]: | |
| reviewer_id = clean_text(payload.get("reviewer_id"), max_length=80) | |
| if not reviewer_id: | |
| raise ValueError("Reviewer is required.") | |
| data = load_db() | |
| record = find_document(data, document_id) | |
| if not record: | |
| raise ValueError("Document not found.") | |
| assignment = find_assignment(record, reviewer_id) | |
| if not assignment: | |
| raise ValueError("Reviewer is not assigned to this document.") | |
| return data, record, assignment | |
| def run(host: str = "127.0.0.1", port: int = 4173) -> None: | |
| ensure_storage() | |
| server = ThreadingHTTPServer((host, port), ReviewHandler) | |
| print(f"SimpleDocReview running at http://{host}:{port}") | |
| try: | |
| server.serve_forever() | |
| except KeyboardInterrupt: | |
| print("\nShutting down SimpleDocReview.") | |
| finally: | |
| server.server_close() | |
| if __name__ == "__main__": | |
| run() | |