Download backend/app/api/endpoints/visual.py from Ramosh99/cognify: direct link, hf CLI and curl.
- Browser
- Download file 15.8 kB
-
https://huggingface.co/spaces/Ramosh99/cognify/resolve/main/backend/app/api/endpoints/visual.py
- Command line
-
hf download hf://spaces/Ramosh99/cognify/backend/app/api/endpoints/visual.py
-
curl -L -o visual.py https://huggingface.co/spaces/Ramosh99/cognify/resolve/main/backend/app/api/endpoints/visual.py
15.8 kB
| """ | |
| Visual Explain API β long-form article with interleaved SVG diagram blocks. | |
| Single LLM call returns: title, sections[], references[] | |
| sections[] is an ordered list of TextSection | ImageSection objects. | |
| """ | |
| import json | |
| import re | |
| from fastapi import APIRouter, Depends, HTTPException | |
| from fastapi.responses import StreamingResponse | |
| from pydantic import BaseModel, TypeAdapter | |
| from typing import Dict, List, Optional, Literal, Tuple, Union, Generator | |
| from app.agents.note_composer_agent import compose_note, compose_note_events | |
| from app.agents.research_note_graph import research_topic | |
| from app.agents.visual_tools import build_diagram | |
| from app.core.auth import get_current_user_id | |
| from app.services.rag_service import rag_service | |
| from app.services.llm_service import llm_service | |
| from app.services import layout_solver as ls | |
| router = APIRouter(prefix="/visual", tags=["Visual"]) | |
| # ββ Request ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Max chars per RAG chunk sent to LLM β keeps total input well under limits | |
| _CHUNK_CHAR_LIMIT = 350 | |
| class VisualRequest(BaseModel): | |
| concept: str | |
| learner_type: str = "Visual" | |
| topic: Optional[str] = None | |
| top_k: int = 3 # 3 sharp chunks is plenty for the article | |
| # ββ Shared sub-models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Reference(BaseModel): | |
| num: int | |
| excerpt: str | |
| topic: Optional[str] = None | |
| source: Optional[str] = None | |
| score: float = 0.0 | |
| class LaidOutNode(BaseModel): | |
| id: str | |
| label: str | |
| color: str = "#6366f1" | |
| x: float | |
| y: float | |
| w: float | |
| h: float | |
| shape: Literal["rect", "circle"] = "rect" | |
| icon: Optional[dict] = None | |
| type: Optional[str] = None | |
| class LaidOutEdge(BaseModel): | |
| source: str | |
| target: str | |
| label: Optional[str] = None | |
| points: List[Tuple[float, float]] | |
| marker: Literal["arrow", "none"] = "arrow" | |
| style: Optional[str] = None | |
| class DiagramData(BaseModel): | |
| title: str = "" | |
| layout_type: str | |
| theme: str = "developer-dark" | |
| viewbox: Dict[str, float] | |
| nodes: List[LaidOutNode] | |
| edges: List[LaidOutEdge] | |
| # ββ Section types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TextSection(BaseModel): | |
| type: Literal["text"] | |
| heading: Optional[str] = None | |
| body: str | |
| class ImageSection(BaseModel): | |
| type: Literal["image"] | |
| caption: str | |
| diagram: DiagramData | |
| purpose: Optional[str] = None | |
| placement_reason: Optional[str] = None | |
| class SearchedImageSection(BaseModel): | |
| type: Literal["searched_image"] | |
| caption: str | |
| title: str | |
| image: str | |
| thumbnail: str | |
| url: str | |
| source: str | |
| purpose: Optional[str] = None | |
| placement_reason: Optional[str] = None | |
| Section = Union[TextSection, ImageSection, SearchedImageSection] | |
| SectionAdapter = TypeAdapter(Section) | |
| # ββ Response βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class VisualResponse(BaseModel): | |
| title: str | |
| note_type: Optional[str] = None | |
| sections: List[Section] | |
| references: List[Reference] | |
| # ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _FALLBACK_COLORS = ["#06b6d4", "#8b5cf6", "#10b981", "#f59e0b", "#ef4444", "#f97316"] | |
| VALID_HEX = re.compile(r"^#[0-9a-fA-F]{6}$") | |
| def _safe_color(c: str, idx: int) -> str: | |
| return c if VALID_HEX.match(c) else _FALLBACK_COLORS[idx % len(_FALLBACK_COLORS)] | |
| _MAX_NODES = 10 | |
| def _parse_diagram(raw: dict, fallback_label: str) -> DiagramData: | |
| """LLM-emitted graph plan β solved layout with geometry.""" | |
| raw_nodes = raw.get("nodes", []) or [] | |
| in_nodes: List[ls.InputNode] = [] | |
| seen_ids: set[str] = set() | |
| for i, n in enumerate(raw_nodes[:_MAX_NODES]): | |
| nid = str(n.get("id") or f"n{i+1}") | |
| if nid in seen_ids: | |
| nid = f"{nid}_{i}" | |
| seen_ids.add(nid) | |
| in_nodes.append(ls.InputNode( | |
| id=nid, | |
| label=str(n.get("label", ""))[:60].strip() or f"Node {i+1}", | |
| color=_safe_color(n.get("color", ""), i), | |
| role=(n.get("role") or None), | |
| )) | |
| if not in_nodes: | |
| in_nodes = [ls.InputNode(id="n1", label=fallback_label or "Key concept", color="#6366f1")] | |
| valid_ids = {n.id for n in in_nodes} | |
| in_edges: List[ls.InputEdge] = [] | |
| for e in (raw.get("edges", []) or [])[:30]: | |
| src = str(e.get("source") or e.get("from") or "") | |
| dst = str(e.get("target") or e.get("to") or "") | |
| if src in valid_ids and dst in valid_ids and src != dst: | |
| in_edges.append(ls.InputEdge( | |
| src=src, dst=dst, | |
| label=(str(e["label"])[:30] if e.get("label") else None), | |
| )) | |
| plan = ls.GraphPlan( | |
| title=str(raw.get("title", fallback_label))[:60], | |
| nodes=in_nodes, | |
| edges=in_edges, | |
| intent=(raw.get("intent") or None), | |
| ) | |
| solved = ls.solve(plan) | |
| return DiagramData( | |
| title=solved.title, | |
| layout_type=solved.layout_type, | |
| viewbox=solved.viewbox, | |
| nodes=[ | |
| LaidOutNode( | |
| id=n.id, label=n.label, color=n.color, | |
| x=n.x, y=n.y, w=n.w, h=n.h, shape=n.shape, | |
| ) for n in solved.nodes | |
| ], | |
| edges=[ | |
| LaidOutEdge( | |
| source=e.src, target=e.dst, label=e.label, | |
| points=e.points, marker=e.marker, | |
| ) for e in solved.edges | |
| ], | |
| ) | |
| # def _parse_sections(raw_sections: list, concept: str) -> List[Section]: | |
| # sections: List[Section] = [] | |
| # for s in raw_sections: | |
| # t = s.get("type") | |
| # if t == "text": | |
| # body = s.get("body", "").strip() | |
| # if body: | |
| # sections.append(TextSection( | |
| # type="text", | |
| # heading=s.get("heading") or None, | |
| # body=body, | |
| # )) | |
| # elif t == "image": | |
| # diagram_raw = s.get("diagram", {}) | |
| # sections.append(ImageSection( | |
| # type="image", | |
| # caption=s.get("caption", "Diagram")[:200], | |
| # diagram=_parse_diagram(diagram_raw, concept), | |
| # )) | |
| # return sections or [TextSection(type="text", body="No content was generated. Please try again.")] | |
| # def _add_default_diagrams(sections: List[Section], concept: str) -> List[Section]: | |
| # if any(getattr(section, "type", None) == "image" for section in sections): | |
| # return sections | |
| # out: List[Section] = [] | |
| # text_seen = 0 | |
| # for section in sections: | |
| # out.append(section) | |
| # if section.type != "text": | |
| # continue | |
| # text_seen += 1 | |
| # if text_seen in {1, 3}: | |
| # diagram_text = f"{section.heading or concept}\n\n{section.body}" | |
| # diagram = DiagramData.model_validate(build_diagram(diagram_text).model_dump()) | |
| # out.append(ImageSection( | |
| # type="image", | |
| # caption=f"Auto-generated visual map for {section.heading or concept}", | |
| # diagram=diagram, | |
| # )) | |
| # return out | |
| # def _fallback_article_data(concept: str, rag_results: List[dict]) -> dict: | |
| # source_hint = "" | |
| # references = [] | |
| # for i, result in enumerate(rag_results[:3], start=1): | |
| # excerpt = result.get("text", "")[:100] | |
| # if excerpt: | |
| # references.append({"num": i, "excerpt": excerpt}) | |
| # source_hint += f" [{i}]" | |
| # intro_cite = " [1]" if references else "" | |
| # return { | |
| # "title": f"{concept.strip()[:80] or 'Visual Explanation'}", | |
| # "sections": [ | |
| # { | |
| # "type": "text", | |
| # "body": ( | |
| # f"{concept} can be understood as a connected system of ideas rather than a single isolated fact." | |
| # f"{intro_cite} Start by identifying the main state, process, or object, then trace how information, " | |
| # "control, or cause-and-effect moves through it." | |
| # ), | |
| # }, | |
| # { | |
| # "type": "text", | |
| # "heading": "Core Structure", | |
| # "body": ( | |
| # "A useful visual explanation separates the concept into nodes and transitions. Nodes represent " | |
| # "important conditions, stages, or components. Transitions show what causes movement from one node " | |
| # "to another. This is especially helpful for state-machine topics, workflows, protocols, and systems " | |
| # "where behavior changes after an event." | |
| # ), | |
| # }, | |
| # { | |
| # "type": "text", | |
| # "heading": "How To Read It", | |
| # "body": ( | |
| # "Read the diagram from the starting point, follow each arrow, and ask what event or rule makes the " | |
| # "system change. Loops usually mean repeated behavior. Branches usually mean decisions. End states " | |
| # "show completion, failure, or a stable condition." | |
| # ), | |
| # }, | |
| # ], | |
| # "references": references, | |
| # } | |
| # def _strip_fences(raw: str) -> str: | |
| # clean = re.sub(r"^```(?:json)?\s*", "", raw.strip(), flags=re.MULTILINE) | |
| # clean = re.sub(r"\s*```$", "", clean.strip(), flags=re.MULTILINE) | |
| # return clean.strip() | |
| def _references_from_composer(composer_refs, rag_results: List[dict]) -> List[Reference]: | |
| references: List[Reference] = [] | |
| for ref in composer_refs: | |
| num = ref.num | |
| idx = num - 1 | |
| rag = rag_results[idx] if 0 <= idx < len(rag_results) else {} | |
| references.append(Reference( | |
| num=num, | |
| excerpt=(ref.excerpt or rag.get("text", ""))[:120], | |
| topic=rag.get("topic"), | |
| source=rag.get("url") or rag.get("source"), | |
| score=rag.get("score", 0.0), | |
| )) | |
| return references | |
| def _numbered_context(results: List[dict]) -> str: | |
| return "\n\n".join( | |
| f"[{i+1}] {r['text'][:_CHUNK_CHAR_LIMIT]}" for i, r in enumerate(results) if r.get("text") | |
| ) | |
| # ββ Endpoint βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def visual_explain( | |
| body: VisualRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| ): | |
| if not body.concept.strip(): | |
| raise HTTPException(status_code=400, detail="Concept must not be empty.") | |
| # 1. Retrieve RAG context β scoped to this user | |
| rag_results = rag_service.retrieve( | |
| query=body.concept, | |
| user_id=user_id, | |
| top_k=body.top_k, | |
| filter_topic=body.topic or None, | |
| ) | |
| research = research_topic(body.concept) | |
| source_results = [*rag_results, *research["sources"]] | |
| numbered_context = _numbered_context(source_results) | |
| # 2. Single LLM call β full article JSON | |
| note = compose_note( | |
| concept=body.concept, | |
| numbered_context=numbered_context, | |
| learner_type=body.learner_type, | |
| rag_results=source_results, | |
| ) | |
| return VisualResponse( | |
| title=note.title, | |
| note_type=note.note_type, | |
| sections=[SectionAdapter.validate_python(section.model_dump()) for section in note.sections], | |
| references=_references_from_composer(note.references, source_results), | |
| ) | |
| # ββ SSE Streaming endpoint βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _sse(event: str, data: dict) -> str: | |
| """Format a single Server-Sent Event.""" | |
| payload = json.dumps(data, ensure_ascii=False) | |
| return f"event: {event}\ndata: {payload}\n\n" | |
| def _stream_visual(body: VisualRequest, user_id: str) -> Generator[str, None, None]: | |
| yield _sse("status", { | |
| "phase": "retrieving_sources", | |
| "message": "Retrieving your source passages...", | |
| "detail": body.topic or body.concept, | |
| }) | |
| """Generator that yields SSE events: title β section (one per chunk) β references β done.""" | |
| # 1. RAG β scoped to this user | |
| rag_results = rag_service.retrieve( | |
| query=body.concept, | |
| user_id=user_id, | |
| top_k=body.top_k, | |
| filter_topic=body.topic or None, | |
| ) | |
| yield _sse("status", { | |
| "phase": "classifying_topic", | |
| "message": "Classifying topic domain and sensitivity...", | |
| "detail": body.concept, | |
| }) | |
| yield _sse("status", { | |
| "phase": "researching_sources", | |
| "message": "Researching public sources for this topic...", | |
| "detail": "Wikipedia and web search", | |
| }) | |
| research = research_topic(body.concept) | |
| source_results = [*rag_results, *research["sources"]] | |
| profile = research["profile"] | |
| yield _sse("status", { | |
| "phase": "researching_sources", | |
| "message": f"Found {len(source_results)} source passage(s).", | |
| "detail": f"{profile['topic_domain']} Β· {profile['sensitivity_level']} sensitivity", | |
| }) | |
| numbered_context = _numbered_context(source_results) | |
| for event in compose_note_events( | |
| concept=body.concept, | |
| numbered_context=numbered_context, | |
| learner_type=body.learner_type, | |
| rag_results=source_results, | |
| ): | |
| if event["event"] == "references_raw": | |
| references = _references_from_composer(event["data"], source_results) | |
| yield _sse("references", {"references": [r.model_dump() for r in references]}) | |
| else: | |
| yield _sse(event["event"], event["data"]) | |
| # 6. Done | |
| yield _sse("done", {}) | |
| def visual_explain_stream( | |
| body: VisualRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| ): | |
| """SSE endpoint β emits sections progressively. Only uses the user's own documents.""" | |
| if not body.concept.strip(): | |
| raise HTTPException(status_code=400, detail="Concept must not be empty.") | |
| return StreamingResponse( | |
| _stream_visual(body, user_id), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "X-Accel-Buffering": "no", | |
| }, | |
| ) | |
| # ββ Quick diagram from selected text ββββββββββββββββββββββββββββββββββββββββββ | |
| class QuickDiagramRequest(BaseModel): | |
| text: str | |
| class QuickDiagramResponse(BaseModel): | |
| diagram: DiagramData | |
| def quick_diagram( | |
| body: QuickDiagramRequest, | |
| user_id: str = Depends(get_current_user_id), | |
| ): | |
| """Generate a single diagram from a user-selected text snippet.""" | |
| text = body.text.strip() | |
| if not text: | |
| raise HTTPException(status_code=400, detail="text must not be empty.") | |
| diagram = build_diagram(text) | |
| return QuickDiagramResponse(diagram=DiagramData.model_validate(diagram.model_dump())) | |