Post Audit MVP — Gradio Space (hybrid rules + Gemma 4 E4B on Modal)
Browse files- README.md +15 -6
- app.py +94 -0
- audit_client.py +205 -0
- examples.py +30 -0
- merge.py +104 -0
- platform_config.py +51 -0
- prompts.py +128 -0
- render.py +260 -0
- requirements.txt +6 -0
- rules.py +282 -0
- static/viewer.html +321 -0
README.md
CHANGED
|
@@ -1,13 +1,22 @@
|
|
| 1 |
---
|
| 2 |
title: Post Audit
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Post Audit
|
| 3 |
+
emoji: 📋
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "5.12.0"
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
short_description: Audit posts against goal & audience (Gemma 4 + rules)
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Post Audit
|
| 15 |
+
|
| 16 |
+
Brief-aware social post audit for the [build-small-hackathon](https://huggingface.co/build-small-hackathon) Backyard AI track.
|
| 17 |
+
|
| 18 |
+
**Model:** Gemma 4 E4B (4.5B effective parameters, ≤32B hackathon limit)
|
| 19 |
+
**Inference:** Modal GPU endpoint
|
| 20 |
+
**Host:** Rule linters + deterministic score recomputation
|
| 21 |
+
|
| 22 |
+
Set Space secret `MODAL_AUDIT_URL` to your deployed Modal web endpoint base URL (without trailing `/audit`).
|
app.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Post Audit — Gradio Space entrypoint."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
|
| 7 |
+
from audit_client import backend_label, call_modal_audit
|
| 8 |
+
from examples import EXAMPLE_CHAT_DUMP, EXAMPLE_WEBINAR
|
| 9 |
+
from merge import merge_audit, viewer_payload
|
| 10 |
+
from render import render_report_html
|
| 11 |
+
from rules import run_rules
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def run_audit(platform: str, goal: str, audience: str, post: str):
|
| 15 |
+
if not post.strip():
|
| 16 |
+
return "<p>Enter a post to audit.</p>", {}
|
| 17 |
+
|
| 18 |
+
rule_warnings = run_rules(platform, goal, audience, post)
|
| 19 |
+
try:
|
| 20 |
+
llm_payload = call_modal_audit(platform, goal, audience, post)
|
| 21 |
+
except Exception as exc:
|
| 22 |
+
err = f"<p style='color:#b3261e'>Audit failed: {exc}</p>"
|
| 23 |
+
return err, {}
|
| 24 |
+
|
| 25 |
+
merged = merge_audit(llm_payload, rule_warnings)
|
| 26 |
+
view = viewer_payload(merged)
|
| 27 |
+
report_html = render_report_html(view)
|
| 28 |
+
|
| 29 |
+
status = (
|
| 30 |
+
f"<p><strong>Done.</strong> Inference: {backend_label()}. "
|
| 31 |
+
f"Rule warnings: {len(rule_warnings)}.</p>"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
return status + report_html, merged
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def load_example(example: dict):
|
| 38 |
+
return example["platform"], example["goal"], example["audience"], example["post"]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
with gr.Blocks(title="Post Audit", theme=gr.themes.Soft()) as demo:
|
| 42 |
+
gr.Markdown(
|
| 43 |
+
"""
|
| 44 |
+
# Post Audit
|
| 45 |
+
Audit a social post against your **goal** and **audience** before you publish.
|
| 46 |
+
|
| 47 |
+
Hybrid pipeline: deterministic rule linters + **Gemma 4 E4B** (4.5B effective) on Modal.
|
| 48 |
+
First run may take ~30s while the GPU container starts.
|
| 49 |
+
"""
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
with gr.Row():
|
| 53 |
+
platform = gr.Dropdown(
|
| 54 |
+
choices=["Telegram", "LinkedIn", "X/Twitter", "Other"],
|
| 55 |
+
value="Telegram",
|
| 56 |
+
label="Platform",
|
| 57 |
+
)
|
| 58 |
+
goal = gr.Textbox(label="Goal", placeholder="Register attendees for Thursday 7pm webinar")
|
| 59 |
+
audience = gr.Textbox(
|
| 60 |
+
label="Audience",
|
| 61 |
+
placeholder="Product managers who own product metrics",
|
| 62 |
+
)
|
| 63 |
+
post = gr.Textbox(
|
| 64 |
+
label="Post draft",
|
| 65 |
+
lines=10,
|
| 66 |
+
placeholder="Paste your post here…",
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
with gr.Row():
|
| 70 |
+
audit_btn = gr.Button("Run audit", variant="primary")
|
| 71 |
+
ex_webinar = gr.Button("Load example: weak webinar CTA")
|
| 72 |
+
ex_chat = gr.Button("Load example: chat dump")
|
| 73 |
+
|
| 74 |
+
status_report = gr.HTML()
|
| 75 |
+
|
| 76 |
+
with gr.Accordion("Raw JSON", open=False):
|
| 77 |
+
raw_json = gr.JSON(label="Pipeline output")
|
| 78 |
+
|
| 79 |
+
audit_btn.click(
|
| 80 |
+
fn=run_audit,
|
| 81 |
+
inputs=[platform, goal, audience, post],
|
| 82 |
+
outputs=[status_report, raw_json],
|
| 83 |
+
)
|
| 84 |
+
ex_webinar.click(
|
| 85 |
+
fn=lambda: load_example(EXAMPLE_WEBINAR),
|
| 86 |
+
outputs=[platform, goal, audience, post],
|
| 87 |
+
)
|
| 88 |
+
ex_chat.click(
|
| 89 |
+
fn=lambda: load_example(EXAMPLE_CHAT_DUMP),
|
| 90 |
+
outputs=[platform, goal, audience, post],
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
if __name__ == "__main__":
|
| 94 |
+
demo.launch()
|
audit_client.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP client for Modal audit endpoint with mock fallback."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import urllib.error
|
| 8 |
+
import urllib.request
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from merge import parse_llm_json
|
| 12 |
+
from prompts import FEW_SHOT_ASSISTANT, build_messages
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_modal_url() -> str | None:
|
| 16 |
+
return os.environ.get("MODAL_AUDIT_URL") or os.environ.get("MODAL_AUDIT_ENDPOINT")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_modal_timeout() -> float:
|
| 20 |
+
# Warm latency is ~50s, but a cold container must download + load the ~16 GB
|
| 21 |
+
# model first, which pushes the first request past 2 min. Generous default.
|
| 22 |
+
return float(os.environ.get("MODAL_AUDIT_TIMEOUT", "300"))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_ollama_model() -> str | None:
|
| 26 |
+
"""Local Ollama model tag (e.g. 'gemma3:4b'); enables the local-LLM path."""
|
| 27 |
+
return os.environ.get("OLLAMA_MODEL")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def get_ollama_url() -> str:
|
| 31 |
+
return os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_ollama_timeout() -> float:
|
| 35 |
+
# Local CPU/Metal inference is slow: a full audit is ~2.5 min on an M1 with
|
| 36 |
+
# gemma3:4b, and the first (cold) call adds model-load time. Generous default.
|
| 37 |
+
return float(os.environ.get("OLLAMA_TIMEOUT", "300"))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def backend_label() -> str:
|
| 41 |
+
"""Human-readable description of the inference backend run() will use."""
|
| 42 |
+
if get_modal_url():
|
| 43 |
+
return "live Gemma 4 E4B on Modal"
|
| 44 |
+
model = get_ollama_model()
|
| 45 |
+
if model:
|
| 46 |
+
return f"local Ollama ({model})"
|
| 47 |
+
return "mock LLM (set MODAL_AUDIT_URL or OLLAMA_MODEL)"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def call_modal_audit(
|
| 51 |
+
platform: str,
|
| 52 |
+
goal: str,
|
| 53 |
+
audience: str,
|
| 54 |
+
post: str,
|
| 55 |
+
*,
|
| 56 |
+
timeout: float | None = None,
|
| 57 |
+
) -> dict[str, Any]:
|
| 58 |
+
"""Dispatch to a backend: Modal endpoint, local Ollama, or deterministic mock."""
|
| 59 |
+
if timeout is None:
|
| 60 |
+
timeout = get_modal_timeout()
|
| 61 |
+
url = get_modal_url()
|
| 62 |
+
if not url:
|
| 63 |
+
if get_ollama_model():
|
| 64 |
+
return _call_ollama(platform, goal, audience, post)
|
| 65 |
+
return _mock_llm_response(platform, goal, audience, post)
|
| 66 |
+
|
| 67 |
+
payload = json.dumps(
|
| 68 |
+
{
|
| 69 |
+
"platform": platform,
|
| 70 |
+
"goal": goal,
|
| 71 |
+
"audience": audience,
|
| 72 |
+
"post": post,
|
| 73 |
+
}
|
| 74 |
+
).encode("utf-8")
|
| 75 |
+
# Modal serves a single fastapi_endpoint at the root of its URL — no path suffix.
|
| 76 |
+
headers = {"Content-Type": "application/json"}
|
| 77 |
+
token = os.environ.get("MODAL_AUDIT_TOKEN")
|
| 78 |
+
if token:
|
| 79 |
+
headers["X-Audit-Token"] = token
|
| 80 |
+
req = urllib.request.Request(
|
| 81 |
+
url.rstrip("/"),
|
| 82 |
+
data=payload,
|
| 83 |
+
headers=headers,
|
| 84 |
+
method="POST",
|
| 85 |
+
)
|
| 86 |
+
try:
|
| 87 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 88 |
+
data = json.loads(resp.read().decode("utf-8"))
|
| 89 |
+
except urllib.error.HTTPError as exc:
|
| 90 |
+
body = exc.read().decode("utf-8", errors="replace")
|
| 91 |
+
raise RuntimeError(f"Modal HTTP {exc.code}: {body}") from exc
|
| 92 |
+
|
| 93 |
+
if "raw" in data:
|
| 94 |
+
return parse_llm_json(data["raw"])
|
| 95 |
+
return data
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _ollama_chat(model: str, messages: list[dict[str, str]], timeout: float) -> str:
|
| 99 |
+
"""POST messages to Ollama's /api/chat with JSON-constrained output; return content."""
|
| 100 |
+
body = json.dumps(
|
| 101 |
+
{
|
| 102 |
+
"model": model,
|
| 103 |
+
"messages": messages,
|
| 104 |
+
"stream": False,
|
| 105 |
+
"format": "json", # constrain output to valid JSON
|
| 106 |
+
"options": {"temperature": 0, "num_predict": 2048},
|
| 107 |
+
}
|
| 108 |
+
).encode("utf-8")
|
| 109 |
+
req = urllib.request.Request(
|
| 110 |
+
get_ollama_url().rstrip("/") + "/api/chat",
|
| 111 |
+
data=body,
|
| 112 |
+
headers={"Content-Type": "application/json"},
|
| 113 |
+
method="POST",
|
| 114 |
+
)
|
| 115 |
+
try:
|
| 116 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 117 |
+
data = json.loads(resp.read().decode("utf-8"))
|
| 118 |
+
except urllib.error.HTTPError as exc:
|
| 119 |
+
detail = exc.read().decode("utf-8", errors="replace")
|
| 120 |
+
raise RuntimeError(f"Ollama HTTP {exc.code}: {detail}") from exc
|
| 121 |
+
except urllib.error.URLError as exc:
|
| 122 |
+
raise RuntimeError(
|
| 123 |
+
f"Cannot reach Ollama at {get_ollama_url()} — is it running? ({exc.reason})"
|
| 124 |
+
) from exc
|
| 125 |
+
return data.get("message", {}).get("content", "")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _call_ollama(
|
| 129 |
+
platform: str,
|
| 130 |
+
goal: str,
|
| 131 |
+
audience: str,
|
| 132 |
+
post: str,
|
| 133 |
+
*,
|
| 134 |
+
timeout: float | None = None,
|
| 135 |
+
) -> dict[str, Any]:
|
| 136 |
+
"""Run the audit against a local Ollama model. First call may be slow (model load)."""
|
| 137 |
+
if timeout is None:
|
| 138 |
+
timeout = get_ollama_timeout()
|
| 139 |
+
model = get_ollama_model()
|
| 140 |
+
messages = build_messages(platform, goal, audience, post)
|
| 141 |
+
raw = _ollama_chat(model, messages, timeout)
|
| 142 |
+
try:
|
| 143 |
+
return parse_llm_json(raw)
|
| 144 |
+
except (json.JSONDecodeError, ValueError):
|
| 145 |
+
# One retry with an explicit instruction, mirroring the Modal path.
|
| 146 |
+
retry = messages + [
|
| 147 |
+
{"role": "user", "content": "Return ONLY valid JSON matching the schema. No other text."}
|
| 148 |
+
]
|
| 149 |
+
return parse_llm_json(_ollama_chat(model, retry, timeout))
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _mock_llm_response(
|
| 153 |
+
platform: str,
|
| 154 |
+
goal: str,
|
| 155 |
+
audience: str,
|
| 156 |
+
post: str,
|
| 157 |
+
) -> dict[str, Any]:
|
| 158 |
+
"""Deterministic mock when Modal URL is unset — uses few-shot shape for webinar-like posts."""
|
| 159 |
+
del platform, goal, audience
|
| 160 |
+
lower = post.lower()
|
| 161 |
+
if "link in bio" in lower or "webinar" in lower:
|
| 162 |
+
return json.loads(FEW_SHOT_ASSISTANT)
|
| 163 |
+
return {
|
| 164 |
+
"briefCheck": {
|
| 165 |
+
"status": "ok",
|
| 166 |
+
"inferred": {
|
| 167 |
+
"goal": "Unclear from post — needs editor review",
|
| 168 |
+
"audience": "In-context colleagues",
|
| 169 |
+
},
|
| 170 |
+
"gaps": [],
|
| 171 |
+
},
|
| 172 |
+
"auditReport": {
|
| 173 |
+
"goalAlignment": {
|
| 174 |
+
"overall": 40,
|
| 175 |
+
"cappedBy": [],
|
| 176 |
+
"dimensions": [
|
| 177 |
+
{"key": "hook", "score": 2, "rationale": "Opening lacks a clear stake or benefit."},
|
| 178 |
+
{"key": "clarity", "score": 2, "rationale": "Multiple topics mixed in one dump."},
|
| 179 |
+
{"key": "audienceFit", "score": 3, "rationale": "Jargon may fit insiders but structure is rough."},
|
| 180 |
+
{"key": "goalService", "score": 2, "rationale": "Does not clearly drive the stated goal actions."},
|
| 181 |
+
{"key": "cta", "score": 2, "rationale": "Call to action is logistics-only or missing deadline."},
|
| 182 |
+
],
|
| 183 |
+
"summary": "Mock audit (set MODAL_AUDIT_URL for live Gemma 4 E4B). Post needs structure and a clearer CTA.",
|
| 184 |
+
},
|
| 185 |
+
"warnings": [
|
| 186 |
+
{
|
| 187 |
+
"code": "MIXED_MESSAGES",
|
| 188 |
+
"severity": "warning",
|
| 189 |
+
"source": "llm",
|
| 190 |
+
"message": "Artifact, task, and logistics appear mixed.",
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
"code": "NO_CLEAR_CTA",
|
| 194 |
+
"severity": "warning",
|
| 195 |
+
"source": "llm",
|
| 196 |
+
"message": "No explicit personal action with a deadline.",
|
| 197 |
+
},
|
| 198 |
+
],
|
| 199 |
+
"rewriteHints": [
|
| 200 |
+
"Lead with why the reader should act now.",
|
| 201 |
+
"Separate artifact, task, and logistics into sections.",
|
| 202 |
+
"Add one explicit CTA with a deadline.",
|
| 203 |
+
],
|
| 204 |
+
},
|
| 205 |
+
}
|
examples.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Demo inputs for the Gradio Space."""
|
| 2 |
+
|
| 3 |
+
EXAMPLE_WEBINAR = {
|
| 4 |
+
"platform": "Telegram",
|
| 5 |
+
"goal": "Register attendees for Thursday 7pm webinar",
|
| 6 |
+
"audience": "Product managers who own product metrics",
|
| 7 |
+
"post": (
|
| 8 |
+
"Webinar on product metrics 🚀🚀 Link in bio!!! "
|
| 9 |
+
"#product #metrics #growth #pm #webinar #training #analytics"
|
| 10 |
+
),
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
EXAMPLE_CHAT_DUMP = {
|
| 14 |
+
"platform": "Telegram",
|
| 15 |
+
"goal": (
|
| 16 |
+
"Activate participants on 3 actions: read materials, write their own "
|
| 17 |
+
"conduit version, refine the schema"
|
| 18 |
+
),
|
| 19 |
+
"audience": "Working group on decision methodology — colleagues in context",
|
| 20 |
+
"post": """Handbook (https://example.com/handbook) with checklists
|
| 21 |
+
|
| 22 |
+
[6/5/26 5:10 PM] Pavel Trubin:
|
| 23 |
+
Conduit zero-version:
|
| 24 |
+
- fragment one with typos and missing context
|
| 25 |
+
- another fragment referencing ontology without link
|
| 26 |
+
|
| 27 |
+
Please improve this conduit and turn it into a schema. Substantive thoughts go on the miro board.
|
| 28 |
+
|
| 29 |
+
Any and all important decisions will be made one way or another — complexity sits on both sides of the moment.""",
|
| 30 |
+
}
|
merge.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Merge LLM output with rule warnings and recompute scores."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
DIMENSION_KEYS = ("hook", "clarity", "audienceFit", "goalService", "cta")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def parse_llm_json(raw: str) -> dict[str, Any]:
|
| 13 |
+
text = raw.strip()
|
| 14 |
+
if text.startswith("```"):
|
| 15 |
+
text = text.split("\n", 1)[-1]
|
| 16 |
+
if text.endswith("```"):
|
| 17 |
+
text = text[:-3]
|
| 18 |
+
text = text.strip()
|
| 19 |
+
start = text.find("{")
|
| 20 |
+
end = text.rfind("}")
|
| 21 |
+
if start == -1 or end == -1:
|
| 22 |
+
raise ValueError("No JSON object found in model output")
|
| 23 |
+
return json.loads(text[start : end + 1])
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def recompute_overall(dimensions: list[dict[str, Any]], capped_by: list[str]) -> int:
|
| 27 |
+
scores = [int(d.get("score", 0)) for d in dimensions if d.get("key") in DIMENSION_KEYS]
|
| 28 |
+
if not scores:
|
| 29 |
+
return 0
|
| 30 |
+
overall = round(sum(scores) / len(scores) / 5 * 100)
|
| 31 |
+
if capped_by:
|
| 32 |
+
overall = min(overall, 39)
|
| 33 |
+
return overall
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def merge_warnings(
|
| 37 |
+
llm_warnings: list[dict[str, Any]],
|
| 38 |
+
rule_warnings: list[dict[str, Any]],
|
| 39 |
+
) -> list[dict[str, Any]]:
|
| 40 |
+
by_code: dict[str, dict[str, Any]] = {}
|
| 41 |
+
for w in llm_warnings:
|
| 42 |
+
code = w.get("code")
|
| 43 |
+
if code:
|
| 44 |
+
by_code[code] = w
|
| 45 |
+
for w in rule_warnings:
|
| 46 |
+
code = w.get("code")
|
| 47 |
+
if code:
|
| 48 |
+
by_code[code] = w # rule wins on conflict
|
| 49 |
+
return list(by_code.values())
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def capped_by_from_warnings(warnings: list[dict[str, Any]]) -> list[str]:
|
| 53 |
+
return sorted({w["code"] for w in warnings if w.get("severity") == "critical" and w.get("code")})
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def merge_audit(
|
| 57 |
+
llm_payload: dict[str, Any],
|
| 58 |
+
rule_warnings: list[dict[str, Any]],
|
| 59 |
+
) -> dict[str, Any]:
|
| 60 |
+
brief = llm_payload.get("briefCheck") or {}
|
| 61 |
+
status = brief.get("status", "ok")
|
| 62 |
+
|
| 63 |
+
if status == "needs_clarification":
|
| 64 |
+
return {"briefCheck": brief, "auditReport": None}
|
| 65 |
+
|
| 66 |
+
audit = llm_payload.get("auditReport")
|
| 67 |
+
if not audit:
|
| 68 |
+
return {"briefCheck": brief, "auditReport": None}
|
| 69 |
+
|
| 70 |
+
ga = audit.get("goalAlignment") or {}
|
| 71 |
+
dimensions = ga.get("dimensions") or []
|
| 72 |
+
llm_warnings = audit.get("warnings") or audit.get("warnings_llm") or []
|
| 73 |
+
rewrite_hints = audit.get("rewriteHints") or []
|
| 74 |
+
|
| 75 |
+
warnings = merge_warnings(llm_warnings, rule_warnings)
|
| 76 |
+
capped = capped_by_from_warnings(warnings)
|
| 77 |
+
overall = recompute_overall(dimensions, capped)
|
| 78 |
+
|
| 79 |
+
goal_alignment = {
|
| 80 |
+
"overall": overall,
|
| 81 |
+
"cappedBy": capped,
|
| 82 |
+
"dimensions": dimensions,
|
| 83 |
+
"summary": ga.get("summary") or "",
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
return {
|
| 87 |
+
"briefCheck": brief,
|
| 88 |
+
"auditReport": {
|
| 89 |
+
"goalAlignment": goal_alignment,
|
| 90 |
+
"warnings": warnings,
|
| 91 |
+
"rewriteHints": rewrite_hints,
|
| 92 |
+
},
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def viewer_payload(merged: dict[str, Any]) -> dict[str, Any]:
|
| 97 |
+
"""Flatten merged output for post-audit-viewer (audit section only)."""
|
| 98 |
+
brief = merged.get("briefCheck")
|
| 99 |
+
audit = merged.get("auditReport")
|
| 100 |
+
if audit is None and brief:
|
| 101 |
+
return brief
|
| 102 |
+
if audit:
|
| 103 |
+
return audit
|
| 104 |
+
return merged
|
platform_config.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Platform-specific thresholds for rule linters."""
|
| 2 |
+
|
| 3 |
+
HASHTAG_LIMITS = {
|
| 4 |
+
"telegram": 3,
|
| 5 |
+
"linkedin": 5,
|
| 6 |
+
"x": 3,
|
| 7 |
+
"twitter": 3,
|
| 8 |
+
"other": 5,
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
ACTIVATING_GOAL_KEYWORDS = (
|
| 12 |
+
"register",
|
| 13 |
+
"sign up",
|
| 14 |
+
"signup",
|
| 15 |
+
"rsvp",
|
| 16 |
+
"join",
|
| 17 |
+
"attend",
|
| 18 |
+
"apply",
|
| 19 |
+
"submit",
|
| 20 |
+
"deadline",
|
| 21 |
+
"webinar",
|
| 22 |
+
"event",
|
| 23 |
+
"book",
|
| 24 |
+
"buy",
|
| 25 |
+
"donate",
|
| 26 |
+
"vote",
|
| 27 |
+
"enroll",
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
TIME_PATTERNS = (
|
| 31 |
+
r"\b\d{1,2}[/:]\d{2}\b",
|
| 32 |
+
r"\b\d{1,2}\s*(am|pm)\b",
|
| 33 |
+
r"\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b",
|
| 34 |
+
r"\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\b",
|
| 35 |
+
r"\b\d{1,2}\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\b",
|
| 36 |
+
r"\bby\s+(tomorrow|tonight|eod|end of day)\b",
|
| 37 |
+
r"\b\d{4}-\d{2}-\d{2}\b",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def normalize_platform(platform: str) -> str:
|
| 42 |
+
p = (platform or "other").strip().lower()
|
| 43 |
+
if p in ("x/twitter", "x / twitter"):
|
| 44 |
+
return "x"
|
| 45 |
+
if p.startswith("twitter"):
|
| 46 |
+
return "twitter"
|
| 47 |
+
return p if p in HASHTAG_LIMITS else "other"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def hashtag_limit(platform: str) -> int:
|
| 51 |
+
return HASHTAG_LIMITS.get(normalize_platform(platform), HASHTAG_LIMITS["other"])
|
prompts.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM prompts for post audit (LLM-judgment codes only)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from platform_config import hashtag_limit, normalize_platform
|
| 6 |
+
|
| 7 |
+
SYSTEM_PROMPT = """You are a social media editor. Audit the post against the stated goal and audience. Return STRICTLY valid JSON matching the schema below.
|
| 8 |
+
No text outside JSON: no preamble, no markdown fences, no comments. Response starts with { and ends with }.
|
| 9 |
+
|
| 10 |
+
INPUT (in the user message): platform, goal, audience, post.
|
| 11 |
+
|
| 12 |
+
STEP 0 — brief check (briefCheck).
|
| 13 |
+
- Goal is verifiable if there is an observable outcome and direction (not vague "engagement" but a concrete action or result).
|
| 14 |
+
- Audience is verifiable if role/level/context is clear (not "everyone" or "professionals").
|
| 15 |
+
- Always output inferred.goal and inferred.audience from the post text — your interpretation even if the brief is fine.
|
| 16 |
+
- If a brief field is vague, add a gaps object: field ("goal"|"audience"), reason, candidates (2–4 concrete options).
|
| 17 |
+
- status="ok" if audit is possible (including on inferred). status="needs_clarification" only if goal is completely unverifiable; then auditReport=null.
|
| 18 |
+
|
| 19 |
+
STEP 1 — goal dimensions (5 items, integer scores 1–5). Anchors:
|
| 20 |
+
[hook] 1=first line repels/empty; 3=neutral, not compelling; 5=stakes/benefit/hook clear from line one.
|
| 21 |
+
[clarity] 1=unclear what this is and why; 3=readable but scattered/multi-topic; 5=one clear core, easy to retell.
|
| 22 |
+
[audienceFit] 1=jargon/level wrong for audience; 3=mostly ok, some misses; 5=language, level, examples fit audience.
|
| 23 |
+
[goalService] 1=does not advance goal; 3=partial, gaps remain; 5=directly triggers actions stated in goal.
|
| 24 |
+
[cta] 1=no call to action; 3=present but vague or no deadline; 5=one clear CTA: action + deadline.
|
| 25 |
+
In rationale — one short phrase in the post's language explaining the score.
|
| 26 |
+
|
| 27 |
+
STEP 2 — warnings (warnings). Use ONLY codes from the catalog below (LLM judgment only — rule checks run separately on the host).
|
| 28 |
+
For each triggered warning: code, severity, source="llm", message (post language), evidence (verbatim excerpt from post ≤12 words; omit if not tied to a fragment).
|
| 29 |
+
severity ∈ {info, warning, critical}. Use critical only when the defect blocks the goal; GOAL_ACTION_MISMATCH is always critical.
|
| 30 |
+
|
| 31 |
+
CATALOG (code — default severity — when to fire):
|
| 32 |
+
GOAL_ACTION_MISMATCH — critical — post does not ask for the action stated in the goal.
|
| 33 |
+
BURIED_LEDE — warning — main value/motive buried at the end; critical if goal is activating.
|
| 34 |
+
GOAL_DRIFT — warning — ask is broader/different from declared goal.
|
| 35 |
+
WEAK_DELIVERABLE_NAMING— info — vague ask ("insights", "think about") instead of concrete deliverable.
|
| 36 |
+
NO_CLEAR_CTA — warning — no explicit CTA or CTA is logistics-only; critical if action is mandatory and absent.
|
| 37 |
+
DIFFUSE_ASKS — warning — ≥3 different asks in one post, focus blurred.
|
| 38 |
+
MIXED_MESSAGES — warning — artifact + task + logistics + philosophy mixed together.
|
| 39 |
+
EFFORT_ASYMMETRY — warning — sloppy/raw post while asking for careful work.
|
| 40 |
+
CLICKBAIT — warning — promise not backed by content; critical if strongly misleading.
|
| 41 |
+
TONE_MISMATCH — warning — tone/jargon level mismatches audience.
|
| 42 |
+
COGNITIVE_LOAD — warning — dense jargon + cross-references, hard to hold in mind.
|
| 43 |
+
AI_TELL — info — clichés like "in today's fast-changing world".
|
| 44 |
+
|
| 45 |
+
STEP 3 — goalAlignment assembly (host will recompute overall and cappedBy — still fill them for completeness).
|
| 46 |
+
- summary = 1–2 phrases in post language: what works and what pulls down.
|
| 47 |
+
- rewriteHints = 3–6 concrete edits in post language.
|
| 48 |
+
|
| 49 |
+
LANGUAGE: human-readable fields (rationale, message, summary, reason, candidates, rewriteHints, inferred.*) in the post's language. Keys and enums (code, severity, source, key, status, field) in Latin as in schema.
|
| 50 |
+
|
| 51 |
+
OUTPUT SCHEMA (return exactly one object):
|
| 52 |
+
{
|
| 53 |
+
"briefCheck": {
|
| 54 |
+
"status": "ok" | "needs_clarification",
|
| 55 |
+
"inferred": { "goal": string, "audience": string },
|
| 56 |
+
"gaps": [ { "field": "goal"|"audience", "reason": string, "candidates": [string, ...] } ]
|
| 57 |
+
},
|
| 58 |
+
"auditReport": {
|
| 59 |
+
"goalAlignment": {
|
| 60 |
+
"overall": integer,
|
| 61 |
+
"cappedBy": [string, ...],
|
| 62 |
+
"dimensions": [ { "key": "hook"|"clarity"|"audienceFit"|"goalService"|"cta", "score": 1..5, "rationale": string } ],
|
| 63 |
+
"summary": string
|
| 64 |
+
},
|
| 65 |
+
"warnings": [ { "code": string, "severity": "info"|"warning"|"critical", "source": "llm", "message": string, "evidence": string (optional) } ],
|
| 66 |
+
"rewriteHints": [string, ...]
|
| 67 |
+
} | null
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
HARD RULES: JSON only; scores integers 1–5; codes only from catalog; evidence verbatim from post; no extra fields; auditReport=null when status="needs_clarification"."""
|
| 71 |
+
|
| 72 |
+
FEW_SHOT_USER = """platform: telegram
|
| 73 |
+
goal: Register attendees for Thursday 7pm webinar
|
| 74 |
+
audience: Product managers who own product metrics
|
| 75 |
+
post:
|
| 76 |
+
Webinar on product metrics 🚀🚀 Link in bio!!! #product #metrics #growth #pm #webinar #training #analytics"""
|
| 77 |
+
|
| 78 |
+
FEW_SHOT_ASSISTANT = """{
|
| 79 |
+
"briefCheck": {
|
| 80 |
+
"status": "ok",
|
| 81 |
+
"inferred": { "goal": "Register for product metrics webinar (Thu 7pm)", "audience": "Product managers working with metrics" },
|
| 82 |
+
"gaps": []
|
| 83 |
+
},
|
| 84 |
+
"auditReport": {
|
| 85 |
+
"goalAlignment": {
|
| 86 |
+
"overall": 39,
|
| 87 |
+
"cappedBy": ["GOAL_ACTION_MISMATCH"],
|
| 88 |
+
"dimensions": [
|
| 89 |
+
{ "key": "hook", "score": 2, "rationale": "Opens with generic 'webinar on metrics' — no benefit or hook." },
|
| 90 |
+
{ "key": "clarity", "score": 3, "rationale": "Topic is clear but when/where/how to register is missing." },
|
| 91 |
+
{ "key": "audienceFit", "score": 3, "rationale": "Topic fits PMs but tone is ad-like." },
|
| 92 |
+
{ "key": "goalService", "score": 2, "rationale": "Cannot register: no link in post, no date/time." },
|
| 93 |
+
{ "key": "cta", "score": 2, "rationale": "'Link in bio' is not an action; no registration form." }
|
| 94 |
+
],
|
| 95 |
+
"summary": "Topic is clear but the post does not drive registration: no direct link, date, or clear CTA."
|
| 96 |
+
},
|
| 97 |
+
"warnings": [
|
| 98 |
+
{ "code": "NO_CLEAR_CTA", "severity": "warning", "source": "llm", "message": "CTA is logistics ('link in bio'), not registration.", "evidence": "Link in bio" },
|
| 99 |
+
{ "code": "GOAL_ACTION_MISMATCH", "severity": "critical", "source": "llm", "message": "Goal is registration but post does not enable signing up.", "evidence": "Link in bio" }
|
| 100 |
+
],
|
| 101 |
+
"rewriteHints": [
|
| 102 |
+
"Put a direct registration link in the post, not 'in bio'.",
|
| 103 |
+
"State date and time (Thursday 7pm) in the text.",
|
| 104 |
+
"Replace ad tone with concrete takeaway: what attendees leave with.",
|
| 105 |
+
"Cut hashtags to 2–3 relevant ones."
|
| 106 |
+
]
|
| 107 |
+
}
|
| 108 |
+
}"""
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def build_user_message(platform: str, goal: str, audience: str, post: str) -> str:
|
| 112 |
+
plat = normalize_platform(platform)
|
| 113 |
+
limit = hashtag_limit(platform)
|
| 114 |
+
return f"""platform: {plat}
|
| 115 |
+
hashtag_limit: {limit}
|
| 116 |
+
goal: {goal}
|
| 117 |
+
audience: {audience}
|
| 118 |
+
post:
|
| 119 |
+
{post}"""
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def build_messages(platform: str, goal: str, audience: str, post: str) -> list[dict[str, str]]:
|
| 123 |
+
return [
|
| 124 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 125 |
+
{"role": "user", "content": FEW_SHOT_USER},
|
| 126 |
+
{"role": "assistant", "content": FEW_SHOT_ASSISTANT},
|
| 127 |
+
{"role": "user", "content": build_user_message(platform, goal, audience, post)},
|
| 128 |
+
]
|
render.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Render audit JSON as HTML for Gradio (ported from post-audit-viewer.html)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import html
|
| 6 |
+
import json
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
DIM_LABELS = {
|
| 10 |
+
"hook": "Hook",
|
| 11 |
+
"clarity": "Clarity",
|
| 12 |
+
"audienceFit": "Audience fit",
|
| 13 |
+
"goalService": "Goal service",
|
| 14 |
+
"cta": "Call to action",
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
SEV_ORDER = {"critical": 0, "warning": 1, "info": 2}
|
| 18 |
+
SEV_CLASS = {"critical": "crit", "warning": "warn", "info": "info"}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _esc(s: Any) -> str:
|
| 22 |
+
return html.escape(str(s if s is not None else ""))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _band_color(pct: int) -> str:
|
| 26 |
+
if pct < 40:
|
| 27 |
+
return "var(--crit)"
|
| 28 |
+
if pct < 70:
|
| 29 |
+
return "var(--warn)"
|
| 30 |
+
return "var(--ok)"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _dim_color(score: int) -> str:
|
| 34 |
+
if score <= 2:
|
| 35 |
+
return "var(--crit)"
|
| 36 |
+
if score == 3:
|
| 37 |
+
return "var(--warn)"
|
| 38 |
+
return "var(--ok)"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _gauge(pct: int) -> str:
|
| 42 |
+
r = 60
|
| 43 |
+
c = 2 * 3.14159265 * r
|
| 44 |
+
off = c * (1 - max(0, min(100, pct)) / 100)
|
| 45 |
+
col = _band_color(pct)
|
| 46 |
+
return f"""<div class="gauge">
|
| 47 |
+
<svg width="138" height="138" viewBox="0 0 138 138">
|
| 48 |
+
<circle cx="69" cy="69" r="{r}" fill="none" stroke="#ece6d8" stroke-width="9"/>
|
| 49 |
+
<circle cx="69" cy="69" r="{r}" fill="none" stroke="{col}" stroke-width="9" stroke-linecap="round"
|
| 50 |
+
stroke-dasharray="{c:.1f}" stroke-dashoffset="{off:.1f}" transform="rotate(-90 69 69)"/>
|
| 51 |
+
</svg>
|
| 52 |
+
<div class="num"><b style="color:{col}">{pct}</b><span>of 100</span></div>
|
| 53 |
+
</div>"""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _render_audit(data: dict[str, Any]) -> str:
|
| 57 |
+
ga = data.get("goalAlignment") or {}
|
| 58 |
+
dims = ga.get("dimensions") or []
|
| 59 |
+
warns = sorted(
|
| 60 |
+
data.get("warnings") or [],
|
| 61 |
+
key=lambda w: SEV_ORDER.get(w.get("severity", "info"), 9),
|
| 62 |
+
)
|
| 63 |
+
counts: dict[str, int] = {}
|
| 64 |
+
for w in warns:
|
| 65 |
+
sev = w.get("severity", "info")
|
| 66 |
+
counts[sev] = counts.get(sev, 0) + 1
|
| 67 |
+
count_str = " · ".join(f"{counts[s]} {s}" for s in ("critical", "warning", "info") if counts.get(s))
|
| 68 |
+
|
| 69 |
+
parts = [
|
| 70 |
+
f"""<section class="sec"><div class="head">
|
| 71 |
+
{_gauge(int(ga.get("overall") or 0))}
|
| 72 |
+
<div class="lead">
|
| 73 |
+
<p class="verdict">{_esc(ga.get("summary") or "")}</p>"""
|
| 74 |
+
]
|
| 75 |
+
capped = ga.get("cappedBy") or []
|
| 76 |
+
if capped:
|
| 77 |
+
tags = "".join(f'<span class="tag crit mono">{_esc(c)}</span>' for c in capped)
|
| 78 |
+
parts.append(f'<div class="capped"><span class="lbl">capped by:</span>{tags}</div>')
|
| 79 |
+
parts.append("</div></div></section>")
|
| 80 |
+
|
| 81 |
+
if dims:
|
| 82 |
+
parts.append(
|
| 83 |
+
'<section class="sec"><div class="sec-h"><span>Goal dimensions</span>'
|
| 84 |
+
'<span class="wcount">score / 5</span></div>'
|
| 85 |
+
)
|
| 86 |
+
for dm in dims:
|
| 87 |
+
s = int(dm.get("score") or 0)
|
| 88 |
+
pct = (s / 5) * 100
|
| 89 |
+
col = _dim_color(s)
|
| 90 |
+
key = dm.get("key", "")
|
| 91 |
+
parts.append(
|
| 92 |
+
f"""<div class="dim">
|
| 93 |
+
<div class="name">{_esc(DIM_LABELS.get(key, key))}<span class="k">{_esc(key)}</span></div>
|
| 94 |
+
<div class="body">
|
| 95 |
+
<div class="bar"><div class="track"><div class="fill" style="width:{pct}%;background:{col}"></div></div>
|
| 96 |
+
<div class="sc">{s}/5</div></div>
|
| 97 |
+
<div class="rat">{_esc(dm.get("rationale") or "")}</div>
|
| 98 |
+
</div></div>"""
|
| 99 |
+
)
|
| 100 |
+
parts.append("</section>")
|
| 101 |
+
|
| 102 |
+
if warns:
|
| 103 |
+
parts.append(
|
| 104 |
+
f'<section class="sec"><div class="sec-h"><span>Warnings</span>'
|
| 105 |
+
f'<span class="wcount">{_esc(count_str)}</span></div>'
|
| 106 |
+
)
|
| 107 |
+
for i, w in enumerate(warns):
|
| 108 |
+
cl = SEV_CLASS.get(w.get("severity", "info"), "info")
|
| 109 |
+
src = f'<span class="src">{_esc(w.get("source"))}</span>' if w.get("source") else ""
|
| 110 |
+
ev = (
|
| 111 |
+
f'<div class="ev">{_esc(w.get("evidence"))}</div>'
|
| 112 |
+
if w.get("evidence")
|
| 113 |
+
else ""
|
| 114 |
+
)
|
| 115 |
+
parts.append(
|
| 116 |
+
f"""<div class="w {cl}" style="--i:{i}">
|
| 117 |
+
<div class="row">
|
| 118 |
+
<span class="sev {cl}">{_esc(w.get("severity"))}</span>
|
| 119 |
+
<span class="code">{_esc(w.get("code"))}</span>
|
| 120 |
+
{src}
|
| 121 |
+
</div>
|
| 122 |
+
<div class="msg">{_esc(w.get("message"))}</div>
|
| 123 |
+
{ev}
|
| 124 |
+
</div>"""
|
| 125 |
+
)
|
| 126 |
+
parts.append("</section>")
|
| 127 |
+
|
| 128 |
+
hints = data.get("rewriteHints") or []
|
| 129 |
+
if hints:
|
| 130 |
+
items = "".join(f"<li>{_esc(h)}</li>" for h in hints)
|
| 131 |
+
parts.append(
|
| 132 |
+
f'<section class="sec"><div class="sec-h"><span>Rewrite hints</span></div>'
|
| 133 |
+
f'<ol class="hints">{items}</ol></section>'
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
return "".join(parts)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _render_brief(data: dict[str, Any]) -> str:
|
| 140 |
+
ok = data.get("status") == "ok"
|
| 141 |
+
status_cls = "ok" if ok else "clar"
|
| 142 |
+
status_txt = (
|
| 143 |
+
"Brief accepted — audit uses inferred fields"
|
| 144 |
+
if ok
|
| 145 |
+
else "Needs clarification"
|
| 146 |
+
)
|
| 147 |
+
inferred = data.get("inferred") or {}
|
| 148 |
+
parts = [
|
| 149 |
+
f"""<section class="sec">
|
| 150 |
+
<span class="status {status_cls}"><span class="dot"></span>{status_txt}</span>
|
| 151 |
+
<div class="cards2" style="margin-top:16px">
|
| 152 |
+
<div class="infcard"><div class="t">Goal (inferred)</div><div class="v">{_esc(inferred.get("goal") or "—")}</div></div>
|
| 153 |
+
<div class="infcard"><div class="t">Audience (inferred)</div><div class="v">{_esc(inferred.get("audience") or "—")}</div></div>
|
| 154 |
+
</div></section>"""
|
| 155 |
+
]
|
| 156 |
+
for g in data.get("gaps") or []:
|
| 157 |
+
chips = "".join(f'<span class="chip">{_esc(c)}</span>' for c in g.get("candidates") or [])
|
| 158 |
+
parts.append(
|
| 159 |
+
f"""<section class="sec"><div class="sec-h"><span>Gaps</span></div>
|
| 160 |
+
<div class="gap">
|
| 161 |
+
<span class="fld">{_esc(g.get("field"))}</span>
|
| 162 |
+
<div class="reason">{_esc(g.get("reason") or "")}</div>
|
| 163 |
+
<div class="chips">{chips}</div>
|
| 164 |
+
</div></section>"""
|
| 165 |
+
)
|
| 166 |
+
return "".join(parts)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
REPORT_CSS = """
|
| 170 |
+
:root{
|
| 171 |
+
--paper:#f5f1e8; --card:#fffdf8; --ink:#211f1b; --soft:#5c574d; --faint:#928c7e;
|
| 172 |
+
--rule:#e2dccd; --rule-2:#d3ccb8;
|
| 173 |
+
--crit:#b3261e; --crit-bg:#fbe9e6; --warn:#956000; --warn-bg:#faf0d6; --info:#2b5f8a; --info-bg:#e6eef6;
|
| 174 |
+
--ok:#2f6b34; --ok-bg:#e8f1e3;
|
| 175 |
+
}
|
| 176 |
+
.post-audit-report *{box-sizing:border-box}
|
| 177 |
+
.post-audit-report{
|
| 178 |
+
background:var(--paper); color:var(--ink);
|
| 179 |
+
font-family:"Golos Text",system-ui,sans-serif; font-size:16px; line-height:1.6;
|
| 180 |
+
border:1px solid var(--rule); border-radius:14px; padding:24px 20px;
|
| 181 |
+
}
|
| 182 |
+
.post-audit-report .mono{font-family:"JetBrains Mono",monospace}
|
| 183 |
+
.post-audit-report header.top{border-bottom:2px solid var(--ink);padding-bottom:14px;margin-bottom:20px}
|
| 184 |
+
.post-audit-report header.top .kicker{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--faint)}
|
| 185 |
+
.post-audit-report header.top h1{font-family:Georgia,serif;font-weight:700;font-size:28px;line-height:1.1;margin:6px 0 0}
|
| 186 |
+
.post-audit-report .sec{margin-top:28px}
|
| 187 |
+
.post-audit-report .sec-h{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--faint);
|
| 188 |
+
border-bottom:1px solid var(--rule);padding-bottom:8px;margin-bottom:18px;display:flex;justify-content:space-between;align-items:baseline}
|
| 189 |
+
.post-audit-report .head{display:flex;gap:26px;align-items:center;flex-wrap:wrap}
|
| 190 |
+
.post-audit-report .gauge{flex:0 0 auto;position:relative;width:138px;height:138px}
|
| 191 |
+
.post-audit-report .gauge .num{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
| 192 |
+
.post-audit-report .gauge .num b{font-family:Georgia,serif;font-size:42px;font-weight:700;line-height:1}
|
| 193 |
+
.post-audit-report .gauge .num span{font-size:11px;color:var(--faint);font-family:"JetBrains Mono",monospace;margin-top:2px}
|
| 194 |
+
.post-audit-report .head .lead{flex:1 1 280px;min-width:220px}
|
| 195 |
+
.post-audit-report .head .lead .verdict{font-family:Georgia,serif;font-size:20px;line-height:1.32;font-weight:500;margin:0 0 12px}
|
| 196 |
+
.post-audit-report .capped{display:flex;flex-wrap:wrap;gap:6px}
|
| 197 |
+
.post-audit-report .capped .lbl{font-size:11px;color:var(--faint);font-family:"JetBrains Mono",monospace;align-self:center;margin-right:2px}
|
| 198 |
+
.post-audit-report .tag{font-family:"JetBrains Mono",monospace;font-size:11px;padding:3px 8px;border-radius:6px;border:1px solid;letter-spacing:.02em}
|
| 199 |
+
.post-audit-report .tag.crit{color:var(--crit);background:var(--crit-bg);border-color:#eecfca}
|
| 200 |
+
.post-audit-report .dim{display:grid;grid-template-columns:150px 1fr;gap:14px 18px;align-items:start;padding:14px 0;border-bottom:1px solid var(--rule)}
|
| 201 |
+
.post-audit-report .dim:last-child{border-bottom:none}
|
| 202 |
+
.post-audit-report .dim .name{font-weight:600;font-size:15px}
|
| 203 |
+
.post-audit-report .dim .name .k{display:block;font-family:"JetBrains Mono",monospace;font-size:10.5px;color:var(--faint);font-weight:400;margin-top:2px}
|
| 204 |
+
.post-audit-report .dim .body .bar{display:flex;align-items:center;gap:10px;margin-bottom:7px}
|
| 205 |
+
.post-audit-report .track{flex:1;height:7px;border-radius:4px;background:#ece6d8;overflow:hidden}
|
| 206 |
+
.post-audit-report .fill{height:100%;border-radius:4px}
|
| 207 |
+
.post-audit-report .dim .sc{font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--soft);min-width:30px;text-align:right}
|
| 208 |
+
.post-audit-report .dim .rat{font-size:14px;color:var(--soft);line-height:1.5}
|
| 209 |
+
.post-audit-report .wcount{font-family:"JetBrains Mono",monospace;font-size:11px;color:var(--faint);letter-spacing:.04em}
|
| 210 |
+
.post-audit-report .w{position:relative;background:var(--card);border:1px solid var(--rule);border-left-width:4px;border-radius:10px;padding:13px 16px 15px;margin-bottom:11px}
|
| 211 |
+
.post-audit-report .w.crit{border-left-color:var(--crit)} .post-audit-report .w.warn{border-left-color:var(--warn)} .post-audit-report .w.info{border-left-color:var(--info)}
|
| 212 |
+
.post-audit-report .w .row{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:7px}
|
| 213 |
+
.post-audit-report .sev{font-family:"JetBrains Mono",monospace;font-size:10px;letter-spacing:.12em;text-transform:uppercase;padding:3px 7px;border-radius:5px;font-weight:500}
|
| 214 |
+
.post-audit-report .sev.crit{color:var(--crit);background:var(--crit-bg)} .post-audit-report .sev.warn{color:var(--warn);background:var(--warn-bg)} .post-audit-report .sev.info{color:var(--info);background:var(--info-bg)}
|
| 215 |
+
.post-audit-report .code{font-family:"JetBrains Mono",monospace;font-size:12.5px;font-weight:500}
|
| 216 |
+
.post-audit-report .src{margin-left:auto;font-family:"JetBrains Mono",monospace;font-size:10px;color:var(--faint);border:1px solid var(--rule-2);border-radius:20px;padding:2px 9px}
|
| 217 |
+
.post-audit-report .w .msg{font-size:14.5px;line-height:1.5}
|
| 218 |
+
.post-audit-report .ev{margin-top:10px;font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--soft);background:#f3eee2;border-radius:7px;padding:8px 11px;border-left:2px solid var(--rule-2);white-space:pre-wrap;word-break:break-word}
|
| 219 |
+
.post-audit-report .ev::before{content:"evidence";display:block;font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);margin-bottom:4px}
|
| 220 |
+
.post-audit-report ol.hints{list-style:none;counter-reset:h;margin:0;padding:0}
|
| 221 |
+
.post-audit-report ol.hints li{counter-increment:h;position:relative;padding:11px 0 11px 42px;border-bottom:1px solid var(--rule);font-size:15px;line-height:1.5;color:var(--ink)}
|
| 222 |
+
.post-audit-report ol.hints li:last-child{border-bottom:none}
|
| 223 |
+
.post-audit-report ol.hints li::before{content:counter(h,decimal-leading-zero);position:absolute;left:0;top:11px;font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--faint);font-weight:500}
|
| 224 |
+
.post-audit-report .cards2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
| 225 |
+
@media(max-width:620px){.post-audit-report .cards2{grid-template-columns:1fr}.post-audit-report .dim{grid-template-columns:1fr}}
|
| 226 |
+
.post-audit-report .infcard{background:var(--card);border:1px solid var(--rule);border-radius:11px;padding:15px 17px}
|
| 227 |
+
.post-audit-report .infcard .t{font-family:"JetBrains Mono",monospace;font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:7px}
|
| 228 |
+
.post-audit-report .infcard .v{font-size:15px;line-height:1.5}
|
| 229 |
+
.post-audit-report .gap{background:var(--card);border:1px solid var(--rule);border-radius:11px;padding:15px 17px;margin-top:13px}
|
| 230 |
+
.post-audit-report .gap .fld{display:inline-block;font-family:"JetBrains Mono",monospace;font-size:11px;background:var(--warn-bg);color:var(--warn);padding:3px 8px;border-radius:5px;margin-bottom:8px}
|
| 231 |
+
.post-audit-report .gap .reason{font-size:14.5px;color:var(--soft);margin-bottom:11px}
|
| 232 |
+
.post-audit-report .chips{display:flex;flex-wrap:wrap;gap:7px}
|
| 233 |
+
.post-audit-report .chip{font-size:13px;border:1px solid var(--rule-2);border-radius:20px;padding:5px 13px;background:#fcfaf4}
|
| 234 |
+
.post-audit-report .status{display:inline-flex;align-items:center;gap:8px;font-family:"JetBrains Mono",monospace;font-size:12px;padding:6px 13px;border-radius:8px;font-weight:500}
|
| 235 |
+
.post-audit-report .status.ok{color:var(--ok);background:var(--ok-bg)} .post-audit-report .status.clar{color:var(--warn);background:var(--warn-bg)}
|
| 236 |
+
.post-audit-report .dot{width:7px;height:7px;border-radius:50%;background:currentColor}
|
| 237 |
+
"""
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def render_report_html(payload: dict[str, Any]) -> str:
|
| 241 |
+
"""Build full HTML fragment for gr.HTML from merged or viewer payload."""
|
| 242 |
+
if payload.get("goalAlignment"):
|
| 243 |
+
body = _render_audit(payload)
|
| 244 |
+
subtitle = "Structured audit against your stated goal and audience."
|
| 245 |
+
elif payload.get("status") or payload.get("inferred"):
|
| 246 |
+
body = _render_brief(payload)
|
| 247 |
+
subtitle = "Brief check — clarify gaps before a full audit."
|
| 248 |
+
else:
|
| 249 |
+
body = f'<p class="rat">Unexpected payload: {_esc(json.dumps(payload)[:200])}</p>'
|
| 250 |
+
subtitle = "Report"
|
| 251 |
+
|
| 252 |
+
return f"""<div class="post-audit-report">
|
| 253 |
+
<style>{REPORT_CSS}</style>
|
| 254 |
+
<header class="top">
|
| 255 |
+
<div class="kicker">post audit · pipeline output</div>
|
| 256 |
+
<h1>Post audit report</h1>
|
| 257 |
+
<p>{_esc(subtitle)}</p>
|
| 258 |
+
</header>
|
| 259 |
+
{body}
|
| 260 |
+
</div>"""
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pinned to match the HF Space SDK version in README.md frontmatter (sdk_version).
|
| 2 |
+
# Keep these two in sync so local dev runs the same Gradio as the deployed Space.
|
| 3 |
+
gradio==5.12.0
|
| 4 |
+
# gradio 5.12.0 imports `requests` from its CLI on `import gradio` but does not
|
| 5 |
+
# declare it; the HF base image ships requests, a bare local venv does not.
|
| 6 |
+
requests>=2.31.0
|
rules.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic rule-based warnings for social post audit."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from platform_config import ACTIVATING_GOAL_KEYWORDS, TIME_PATTERNS, hashtag_limit, normalize_platform
|
| 9 |
+
|
| 10 |
+
URL_RE = re.compile(r"https?://\S+", re.I)
|
| 11 |
+
HASHTAG_RE = re.compile(r"#\w+", re.UNICODE)
|
| 12 |
+
CHAT_TIMESTAMP_RE = re.compile(
|
| 13 |
+
r"\[\d{1,2}/\d{1,2}/\d{2,4}\s+\d{1,2}:\d{2}\s*(?:AM|PM)?\]",
|
| 14 |
+
re.I,
|
| 15 |
+
)
|
| 16 |
+
CHAT_NAME_RE = re.compile(r"^\[[^\]]+\]\s+\w[\w\s.-]{0,40}:", re.M)
|
| 17 |
+
ENGAGEMENT_BAIT_RE = re.compile(
|
| 18 |
+
r"\b(tag a friend|comment below|double tap|like and share|drop a .? below|"
|
| 19 |
+
r"share this post|repost if you agree)\b",
|
| 20 |
+
re.I,
|
| 21 |
+
)
|
| 22 |
+
STRUCTURE_MARKERS_RE = re.compile(
|
| 23 |
+
r"(^[\s]*[-*•]\s|\n[\s]*[-*•]\s|^\d+\.\s|\n\d+\.\s|^#{1,3}\s|\*\*[^*]+\*\*)",
|
| 24 |
+
re.M,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def detect_post_language(post: str) -> str:
|
| 29 |
+
if not post.strip():
|
| 30 |
+
return "en"
|
| 31 |
+
cyrillic = sum(1 for c in post if "\u0400" <= c <= "\u04FF")
|
| 32 |
+
return "ru" if cyrillic > len(post) * 0.15 else "en"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _msg(lang: str, en: str, ru: str) -> str:
|
| 36 |
+
return ru if lang == "ru" else en
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _warning(
|
| 40 |
+
code: str,
|
| 41 |
+
severity: str,
|
| 42 |
+
message: str,
|
| 43 |
+
evidence: str | None = None,
|
| 44 |
+
) -> dict[str, Any]:
|
| 45 |
+
w: dict[str, Any] = {
|
| 46 |
+
"code": code,
|
| 47 |
+
"severity": severity,
|
| 48 |
+
"source": "rule",
|
| 49 |
+
"message": message,
|
| 50 |
+
}
|
| 51 |
+
if evidence:
|
| 52 |
+
w["evidence"] = evidence[:120]
|
| 53 |
+
return w
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _first_line(post: str) -> str:
|
| 57 |
+
lines = [ln.strip() for ln in post.strip().splitlines() if ln.strip()]
|
| 58 |
+
return lines[0] if lines else ""
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _evidence_snippet(text: str, max_words: int = 12) -> str:
|
| 62 |
+
words = text.split()
|
| 63 |
+
return " ".join(words[:max_words])
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _goal_is_activating(goal: str) -> bool:
|
| 67 |
+
g = goal.lower()
|
| 68 |
+
return any(kw in g for kw in ACTIVATING_GOAL_KEYWORDS)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _post_has_deadline(post: str) -> bool:
|
| 72 |
+
for pat in TIME_PATTERNS:
|
| 73 |
+
if re.search(pat, post, re.I):
|
| 74 |
+
return True
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _check_hashtag_stuffing(post: str, platform: str, lang: str) -> dict[str, Any] | None:
|
| 79 |
+
tags = HASHTAG_RE.findall(post)
|
| 80 |
+
limit = hashtag_limit(platform)
|
| 81 |
+
if len(tags) <= limit:
|
| 82 |
+
return None
|
| 83 |
+
return _warning(
|
| 84 |
+
"HASHTAG_STUFFING",
|
| 85 |
+
"warning",
|
| 86 |
+
_msg(
|
| 87 |
+
lang,
|
| 88 |
+
f"{len(tags)} hashtags exceed the {normalize_platform(platform)} limit ({limit}).",
|
| 89 |
+
f"{len(tags)} хэштегов — больше порога для {normalize_platform(platform)} ({limit}).",
|
| 90 |
+
),
|
| 91 |
+
_evidence_snippet(" ".join(tags[:6])),
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _check_chat_dump(post: str, lang: str) -> dict[str, Any] | None:
|
| 96 |
+
m = CHAT_TIMESTAMP_RE.search(post) or CHAT_NAME_RE.search(post)
|
| 97 |
+
if not m:
|
| 98 |
+
return None
|
| 99 |
+
return _warning(
|
| 100 |
+
"CHAT_DUMP_FORMAT",
|
| 101 |
+
"warning",
|
| 102 |
+
_msg(
|
| 103 |
+
lang,
|
| 104 |
+
"Timestamps or chat-style name prefixes look like a pasted conversation, not a composed post.",
|
| 105 |
+
"Таймстемпы и подписи «Имя:» — похоже на копипаст чата, а не собранный пост.",
|
| 106 |
+
),
|
| 107 |
+
_evidence_snippet(m.group(0)),
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _check_weak_opening(post: str, lang: str) -> dict[str, Any] | None:
|
| 112 |
+
first = _first_line(post)
|
| 113 |
+
if not first:
|
| 114 |
+
return None
|
| 115 |
+
if URL_RE.fullmatch(first.strip()) or (
|
| 116 |
+
URL_RE.search(first) and len(first.split()) <= 4
|
| 117 |
+
):
|
| 118 |
+
return _warning(
|
| 119 |
+
"WEAK_OPENING",
|
| 120 |
+
"warning",
|
| 121 |
+
_msg(
|
| 122 |
+
lang,
|
| 123 |
+
"First line is a bare link or too thin to hook the reader.",
|
| 124 |
+
"Первая строка — голая ссылка или слабая зацепка.",
|
| 125 |
+
),
|
| 126 |
+
_evidence_snippet(first),
|
| 127 |
+
)
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _check_wall_of_text(post: str, lang: str) -> dict[str, Any] | None:
|
| 132 |
+
for para in post.split("\n\n"):
|
| 133 |
+
p = para.strip()
|
| 134 |
+
if len(p) > 400 and "\n" not in p:
|
| 135 |
+
return _warning(
|
| 136 |
+
"WALL_OF_TEXT",
|
| 137 |
+
"warning",
|
| 138 |
+
_msg(
|
| 139 |
+
lang,
|
| 140 |
+
"Long paragraph without line breaks is hard to scan.",
|
| 141 |
+
"Длинный абзац без переносов — тяжело читать.",
|
| 142 |
+
),
|
| 143 |
+
_evidence_snippet(p),
|
| 144 |
+
)
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _check_no_structure(post: str, lang: str) -> dict[str, Any] | None:
|
| 149 |
+
if len(post) < 280:
|
| 150 |
+
return None
|
| 151 |
+
if STRUCTURE_MARKERS_RE.search(post):
|
| 152 |
+
return None
|
| 153 |
+
if post.count("\n") >= 4:
|
| 154 |
+
return None
|
| 155 |
+
return _warning(
|
| 156 |
+
"NO_STRUCTURE",
|
| 157 |
+
"warning",
|
| 158 |
+
_msg(
|
| 159 |
+
lang,
|
| 160 |
+
"Long post lacks bullets, numbers, or headings — reads as a flat dump.",
|
| 161 |
+
"Длинный пост без списков и иерархии — плоский дамп.",
|
| 162 |
+
),
|
| 163 |
+
_evidence_snippet(_first_line(post) or post[:80]),
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _check_engagement_bait(post: str, lang: str) -> dict[str, Any] | None:
|
| 168 |
+
m = ENGAGEMENT_BAIT_RE.search(post)
|
| 169 |
+
if not m:
|
| 170 |
+
return None
|
| 171 |
+
return _warning(
|
| 172 |
+
"ENGAGEMENT_BAIT",
|
| 173 |
+
"warning",
|
| 174 |
+
_msg(
|
| 175 |
+
lang,
|
| 176 |
+
"Engagement-bait phrasing detected.",
|
| 177 |
+
"Обнаружена «приманка» для вовлечения.",
|
| 178 |
+
),
|
| 179 |
+
_evidence_snippet(m.group(0)),
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _check_bare_link(post: str, lang: str) -> dict[str, Any] | None:
|
| 184 |
+
urls = URL_RE.findall(post)
|
| 185 |
+
if not urls:
|
| 186 |
+
return None
|
| 187 |
+
for url in urls:
|
| 188 |
+
idx = post.find(url)
|
| 189 |
+
before = post[max(0, idx - 80) : idx].strip()
|
| 190 |
+
after = post[idx + len(url) : idx + len(url) + 80].strip()
|
| 191 |
+
context = (before + " " + after).strip()
|
| 192 |
+
if len(context.split()) < 6:
|
| 193 |
+
return _warning(
|
| 194 |
+
"BARE_LINK",
|
| 195 |
+
"info",
|
| 196 |
+
_msg(
|
| 197 |
+
lang,
|
| 198 |
+
"Link appears without framing — say what's inside and why to open it.",
|
| 199 |
+
"Ссылка без рамки — неясно, что внутри и зачем открывать.",
|
| 200 |
+
),
|
| 201 |
+
_evidence_snippet(url),
|
| 202 |
+
)
|
| 203 |
+
return None
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _check_dense_parenthetical(post: str, lang: str) -> dict[str, Any] | None:
|
| 207 |
+
for m in re.finditer(r"\([^)]{80,}\)", post):
|
| 208 |
+
return _warning(
|
| 209 |
+
"DENSE_PARENTHETICAL",
|
| 210 |
+
"info",
|
| 211 |
+
_msg(
|
| 212 |
+
lang,
|
| 213 |
+
"Long parenthetical breaks reading flow.",
|
| 214 |
+
"Длинная вставка в скобках тормозит чтение.",
|
| 215 |
+
),
|
| 216 |
+
_evidence_snippet(m.group(0)),
|
| 217 |
+
)
|
| 218 |
+
return None
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _check_no_deadline(post: str, goal: str, lang: str) -> dict[str, Any] | None:
|
| 222 |
+
if not _goal_is_activating(goal):
|
| 223 |
+
return None
|
| 224 |
+
if _post_has_deadline(post):
|
| 225 |
+
return None
|
| 226 |
+
return _warning(
|
| 227 |
+
"NO_DEADLINE",
|
| 228 |
+
"warning",
|
| 229 |
+
_msg(
|
| 230 |
+
lang,
|
| 231 |
+
"Goal implies a time-bound action, but the post has no date or deadline.",
|
| 232 |
+
"Цель требует действия ко времени, но в посте нет срока.",
|
| 233 |
+
),
|
| 234 |
+
_evidence_snippet(_first_line(post) or post[:60]),
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _check_unresolved_reference(post: str, lang: str) -> dict[str, Any] | None:
|
| 239 |
+
refs = re.search(
|
| 240 |
+
r"\b(link in bio|link in profile|see profile|check my profile|"
|
| 241 |
+
r"registration link|sign up link)\b",
|
| 242 |
+
post,
|
| 243 |
+
re.I,
|
| 244 |
+
)
|
| 245 |
+
if not refs:
|
| 246 |
+
return None
|
| 247 |
+
if URL_RE.search(post):
|
| 248 |
+
return None
|
| 249 |
+
return _warning(
|
| 250 |
+
"UNRESOLVED_REFERENCE",
|
| 251 |
+
"info",
|
| 252 |
+
_msg(
|
| 253 |
+
lang,
|
| 254 |
+
"Registration or resource is referenced but no URL is in the post.",
|
| 255 |
+
"Регистрация упомянута, но ссылки в тексте нет.",
|
| 256 |
+
),
|
| 257 |
+
_evidence_snippet(refs.group(0)),
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def run_rules(
|
| 262 |
+
platform: str,
|
| 263 |
+
goal: str,
|
| 264 |
+
audience: str,
|
| 265 |
+
post: str,
|
| 266 |
+
) -> list[dict[str, Any]]:
|
| 267 |
+
"""Run all rule linters and return warning objects."""
|
| 268 |
+
del audience # reserved for future rule checks
|
| 269 |
+
lang = detect_post_language(post)
|
| 270 |
+
checks = [
|
| 271 |
+
_check_hashtag_stuffing(post, platform, lang),
|
| 272 |
+
_check_chat_dump(post, lang),
|
| 273 |
+
_check_weak_opening(post, lang),
|
| 274 |
+
_check_wall_of_text(post, lang),
|
| 275 |
+
_check_no_structure(post, lang),
|
| 276 |
+
_check_engagement_bait(post, lang),
|
| 277 |
+
_check_bare_link(post, lang),
|
| 278 |
+
_check_dense_parenthetical(post, lang),
|
| 279 |
+
_check_no_deadline(post, goal, lang),
|
| 280 |
+
_check_unresolved_reference(post, lang),
|
| 281 |
+
]
|
| 282 |
+
return [w for w in checks if w is not None]
|
static/viewer.html
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Post Audit — Report Viewer</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,500;0,600;0,700;1,500&family=Golos+Text:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 10 |
+
<style>
|
| 11 |
+
:root{
|
| 12 |
+
--paper:#f5f1e8; --card:#fffdf8; --ink:#211f1b; --soft:#5c574d; --faint:#928c7e;
|
| 13 |
+
--rule:#e2dccd; --rule-2:#d3ccb8;
|
| 14 |
+
--crit:#b3261e; --crit-bg:#fbe9e6; --warn:#956000; --warn-bg:#faf0d6; --info:#2b5f8a; --info-bg:#e6eef6;
|
| 15 |
+
--ok:#2f6b34; --ok-bg:#e8f1e3;
|
| 16 |
+
}
|
| 17 |
+
*{box-sizing:border-box}
|
| 18 |
+
html,body{margin:0}
|
| 19 |
+
body{
|
| 20 |
+
background:var(--paper); color:var(--ink);
|
| 21 |
+
font-family:"Golos Text",sans-serif; font-size:16px; line-height:1.6;
|
| 22 |
+
-webkit-font-smoothing:antialiased;
|
| 23 |
+
background-image:radial-gradient(circle at 18% -10%,#fbf8f0 0,transparent 46%);
|
| 24 |
+
}
|
| 25 |
+
.wrap{max-width:900px;margin:0 auto;padding:38px 26px 90px}
|
| 26 |
+
a{color:var(--info)}
|
| 27 |
+
.mono{font-family:"JetBrains Mono",monospace}
|
| 28 |
+
|
| 29 |
+
/* masthead */
|
| 30 |
+
header.top{border-bottom:2px solid var(--ink);padding-bottom:14px;margin-bottom:26px}
|
| 31 |
+
header.top .kicker{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--faint)}
|
| 32 |
+
header.top h1{font-family:"Lora",serif;font-weight:700;font-size:34px;line-height:1.04;margin:6px 0 0;letter-spacing:-.01em}
|
| 33 |
+
header.top p{margin:7px 0 0;color:var(--soft);font-size:14.5px;max-width:60ch}
|
| 34 |
+
|
| 35 |
+
/* input */
|
| 36 |
+
.io{background:var(--card);border:1px solid var(--rule);border-radius:14px;padding:16px;margin-bottom:30px}
|
| 37 |
+
.io summary{cursor:pointer;font-weight:600;font-size:14px;list-style:none;display:flex;align-items:center;gap:8px}
|
| 38 |
+
.io summary::-webkit-details-marker{display:none}
|
| 39 |
+
.io summary .chev{transition:transform .2s;color:var(--faint)}
|
| 40 |
+
details[open] .io-body{margin-top:14px}
|
| 41 |
+
textarea{width:100%;min-height:150px;resize:vertical;border:1px solid var(--rule-2);border-radius:9px;
|
| 42 |
+
padding:12px 13px;font-family:"JetBrains Mono",monospace;font-size:12.5px;line-height:1.55;background:#fcfaf4;color:var(--ink)}
|
| 43 |
+
textarea:focus{outline:none;border-color:var(--ink)}
|
| 44 |
+
.btns{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
|
| 45 |
+
button{font-family:inherit;font-size:13px;font-weight:500;cursor:pointer;border-radius:8px;padding:8px 14px;border:1px solid var(--rule-2);background:#fcfaf4;color:var(--ink);transition:.15s}
|
| 46 |
+
button:hover{border-color:var(--ink)}
|
| 47 |
+
button.primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}
|
| 48 |
+
button.primary:hover{opacity:.88}
|
| 49 |
+
.err{color:var(--crit);font-size:13px;margin-top:10px;font-family:"JetBrains Mono",monospace;display:none}
|
| 50 |
+
|
| 51 |
+
/* sections */
|
| 52 |
+
.sec{margin-top:34px}
|
| 53 |
+
.sec-h{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--faint);
|
| 54 |
+
border-bottom:1px solid var(--rule);padding-bottom:8px;margin-bottom:18px;display:flex;justify-content:space-between;align-items:baseline}
|
| 55 |
+
|
| 56 |
+
/* score head */
|
| 57 |
+
.head{display:flex;gap:26px;align-items:center;flex-wrap:wrap}
|
| 58 |
+
.gauge{flex:0 0 auto;position:relative;width:138px;height:138px}
|
| 59 |
+
.gauge .num{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
| 60 |
+
.gauge .num b{font-family:"Lora",serif;font-size:42px;font-weight:700;line-height:1}
|
| 61 |
+
.gauge .num span{font-size:11px;color:var(--faint);font-family:"JetBrains Mono",monospace;margin-top:2px}
|
| 62 |
+
.head .lead{flex:1 1 280px;min-width:260px}
|
| 63 |
+
.head .lead .verdict{font-family:"Lora",serif;font-size:21px;line-height:1.32;font-weight:500;margin:0 0 12px}
|
| 64 |
+
.capped{display:flex;flex-wrap:wrap;gap:6px}
|
| 65 |
+
.capped .lbl{font-size:11px;color:var(--faint);font-family:"JetBrains Mono",monospace;align-self:center;margin-right:2px}
|
| 66 |
+
.tag{font-family:"JetBrains Mono",monospace;font-size:11px;padding:3px 8px;border-radius:6px;border:1px solid;letter-spacing:.02em}
|
| 67 |
+
.tag.crit{color:var(--crit);background:var(--crit-bg);border-color:#eecfca}
|
| 68 |
+
|
| 69 |
+
/* dimensions */
|
| 70 |
+
.dim{display:grid;grid-template-columns:150px 1fr;gap:14px 18px;align-items:start;padding:14px 0;border-bottom:1px solid var(--rule)}
|
| 71 |
+
.dim:last-child{border-bottom:none}
|
| 72 |
+
.dim .name{font-weight:600;font-size:15px}
|
| 73 |
+
.dim .name .k{display:block;font-family:"JetBrains Mono",monospace;font-size:10.5px;color:var(--faint);font-weight:400;margin-top:2px}
|
| 74 |
+
.dim .body .bar{display:flex;align-items:center;gap:10px;margin-bottom:7px}
|
| 75 |
+
.track{flex:1;height:7px;border-radius:4px;background:#ece6d8;overflow:hidden}
|
| 76 |
+
.fill{height:100%;border-radius:4px;transform-origin:left;animation:grow .7s cubic-bezier(.22,1,.36,1) both}
|
| 77 |
+
@keyframes grow{from{transform:scaleX(0)}}
|
| 78 |
+
.dim .sc{font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--soft);min-width:30px;text-align:right}
|
| 79 |
+
.dim .rat{font-size:14px;color:var(--soft);line-height:1.5}
|
| 80 |
+
|
| 81 |
+
/* warnings */
|
| 82 |
+
.wcount{font-family:"JetBrains Mono",monospace;font-size:11px;color:var(--faint);letter-spacing:.04em}
|
| 83 |
+
.w{position:relative;background:var(--card);border:1px solid var(--rule);border-left-width:4px;border-radius:10px;
|
| 84 |
+
padding:13px 16px 15px;margin-bottom:11px;animation:rise .5s cubic-bezier(.22,1,.36,1) both;animation-delay:calc(var(--i,0)*55ms)}
|
| 85 |
+
@keyframes rise{from{opacity:0;transform:translateY(8px)}}
|
| 86 |
+
.w.crit{border-left-color:var(--crit)} .w.warn{border-left-color:var(--warn)} .w.info{border-left-color:var(--info)}
|
| 87 |
+
.w .row{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:7px}
|
| 88 |
+
.sev{font-family:"JetBrains Mono",monospace;font-size:10px;letter-spacing:.12em;text-transform:uppercase;padding:3px 7px;border-radius:5px;font-weight:500}
|
| 89 |
+
.sev.crit{color:var(--crit);background:var(--crit-bg)} .sev.warn{color:var(--warn);background:var(--warn-bg)} .sev.info{color:var(--info);background:var(--info-bg)}
|
| 90 |
+
.code{font-family:"JetBrains Mono",monospace;font-size:12.5px;font-weight:500}
|
| 91 |
+
.src{margin-left:auto;font-family:"JetBrains Mono",monospace;font-size:10px;color:var(--faint);border:1px solid var(--rule-2);border-radius:20px;padding:2px 9px}
|
| 92 |
+
.w .msg{font-size:14.5px;line-height:1.5}
|
| 93 |
+
.ev{margin-top:10px;font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--soft);background:#f3eee2;border-radius:7px;padding:8px 11px;border-left:2px solid var(--rule-2);white-space:pre-wrap;word-break:break-word}
|
| 94 |
+
.ev::before{content:"evidence";display:block;font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);margin-bottom:4px}
|
| 95 |
+
|
| 96 |
+
/* hints */
|
| 97 |
+
ol.hints{list-style:none;counter-reset:h;margin:0;padding:0}
|
| 98 |
+
ol.hints li{counter-increment:h;position:relative;padding:11px 0 11px 42px;border-bottom:1px solid var(--rule);font-size:15px;line-height:1.5;color:var(--ink)}
|
| 99 |
+
ol.hints li:last-child{border-bottom:none}
|
| 100 |
+
ol.hints li::before{content:counter(h,decimal-leading-zero);position:absolute;left:0;top:11px;font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--faint);font-weight:500}
|
| 101 |
+
|
| 102 |
+
/* brief view */
|
| 103 |
+
.cards2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
| 104 |
+
@media(max-width:620px){.cards2{grid-template-columns:1fr}.dim{grid-template-columns:1fr}}
|
| 105 |
+
.infcard{background:var(--card);border:1px solid var(--rule);border-radius:11px;padding:15px 17px}
|
| 106 |
+
.infcard .t{font-family:"JetBrains Mono",monospace;font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:7px}
|
| 107 |
+
.infcard .v{font-size:15px;line-height:1.5}
|
| 108 |
+
.gap{background:var(--card);border:1px solid var(--rule);border-radius:11px;padding:15px 17px;margin-top:13px}
|
| 109 |
+
.gap .fld{display:inline-block;font-family:"JetBrains Mono",monospace;font-size:11px;background:var(--warn-bg);color:var(--warn);padding:3px 8px;border-radius:5px;margin-bottom:8px}
|
| 110 |
+
.gap .reason{font-size:14.5px;color:var(--soft);margin-bottom:11px}
|
| 111 |
+
.chips{display:flex;flex-wrap:wrap;gap:7px}
|
| 112 |
+
.chip{font-size:13px;border:1px solid var(--rule-2);border-radius:20px;padding:5px 13px;background:#fcfaf4}
|
| 113 |
+
.status{display:inline-flex;align-items:center;gap:8px;font-family:"JetBrains Mono",monospace;font-size:12px;padding:6px 13px;border-radius:8px;font-weight:500}
|
| 114 |
+
.status.ok{color:var(--ok);background:var(--ok-bg)} .status.clar{color:var(--warn);background:var(--warn-bg)}
|
| 115 |
+
.dot{width:7px;height:7px;border-radius:50%;background:currentColor}
|
| 116 |
+
</style>
|
| 117 |
+
</head>
|
| 118 |
+
<body>
|
| 119 |
+
<div class="wrap">
|
| 120 |
+
<header class="top">
|
| 121 |
+
<div class="kicker">post audit · pipeline output</div>
|
| 122 |
+
<h1>Post audit report</h1>
|
| 123 |
+
<p>Paste pipeline JSON — <span class="mono">AuditReport</span> or <span class="mono">BriefCheck</span>. View is detected automatically.</p>
|
| 124 |
+
</header>
|
| 125 |
+
|
| 126 |
+
<details class="io" open>
|
| 127 |
+
<summary><span class="chev">▾</span> Input JSON</summary>
|
| 128 |
+
<div class="io-body">
|
| 129 |
+
<textarea id="src" spellcheck="false"></textarea>
|
| 130 |
+
<div class="btns">
|
| 131 |
+
<button class="primary" id="render">Render</button>
|
| 132 |
+
<button data-sample="audit">Sample: audit</button>
|
| 133 |
+
<button data-sample="brief">Sample: brief</button>
|
| 134 |
+
<button id="clear">Clear</button>
|
| 135 |
+
</div>
|
| 136 |
+
<div class="err" id="err"></div>
|
| 137 |
+
</div>
|
| 138 |
+
</details>
|
| 139 |
+
|
| 140 |
+
<div id="out"></div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<script type="application/json" id="sample-audit">
|
| 144 |
+
{
|
| 145 |
+
"goalAlignment": {
|
| 146 |
+
"overall": 34,
|
| 147 |
+
"cappedBy": ["BURIED_LEDE", "GOAL_ACTION_MISMATCH"],
|
| 148 |
+
"dimensions": [
|
| 149 |
+
{ "key": "hook", "score": 2, "rationale": "Открывается голой ссылкой и «Кондуит zero-version:». Ни ставки, ни «зачем мне это»." },
|
| 150 |
+
{ "key": "clarity", "score": 2, "rationale": "Артефакт + задача + логистика + философия смешаны в одном дампе из нескольких сообщений." },
|
| 151 |
+
{ "key": "audienceFit", "score": 3, "rationale": "Жаргон уместен для группы в контексте, но сырость и отсутствие структуры не уважают внимание." },
|
| 152 |
+
{ "key": "goalService", "score": 2, "rationale": "Материалы и черновик для реакции даны (плюс), но три заявленных действия пост напрямую не запускает." },
|
| 153 |
+
{ "key": "cta", "score": 2, "rationale": "Призыв = логистика (куда писать), без дедлайна и персонального действия." }
|
| 154 |
+
],
|
| 155 |
+
"summary": "Пост даёт материал для работы, но как мотиватор не собран: причина действовать спрятана в конце, а просьба не совпадает с целью."
|
| 156 |
+
},
|
| 157 |
+
"warnings": [
|
| 158 |
+
{ "code": "BURIED_LEDE", "severity": "critical", "source": "llm", "message": "Мотивирующая рамка — самый сильный аргумент действовать — стоит в самом конце.", "evidence": "Любые и все важные решения так или иначе будут приняты" },
|
| 159 |
+
{ "code": "GOAL_ACTION_MISMATCH", "severity": "critical", "source": "llm", "message": "Цель требует «написать свою версию кондуита», но пост этого не просит — ставит общую задачу «улучшить и превратить в схему».", "evidence": "вот этот наш кондуит улучшить и ... превратить в схему" },
|
| 160 |
+
{ "code": "NO_CLEAR_CTA", "severity": "warning", "source": "llm", "message": "Призыв описывает, куда писать, но не что именно сделать каждому и к какому сроку.", "evidence": "Содержательные мысли лучше добавлять на miro-доску" },
|
| 161 |
+
{ "code": "EFFORT_ASYMMETRY", "severity": "warning", "source": "llm", "message": "Сырой дамп при просьбе о вдумчивой работе демотивирует: «автор не вложился — почему должен я»." },
|
| 162 |
+
{ "code": "WEAK_OPENING", "severity": "warning", "source": "rule", "message": "Первая строка — голая ссылка; на обрезке «…ещё» зацепки нет.", "evidence": "Справочник (https://agsagds.github.io/...) с чеклистами" },
|
| 163 |
+
{ "code": "CHAT_DUMP_FORMAT", "severity": "warning", "source": "rule", "message": "Вставлены таймстемпы и подписи — выглядит как копипаст истории чата, а не собранный пост.", "evidence": "[6/5/26 5:10 PM] Pavel Trubin:" },
|
| 164 |
+
{ "code": "TYPOS", "severity": "warning", "source": "rule", "message": "Опечатки бьют по доверию в посте, который просит о тщательной работе.", "evidence": "учестом · принтяия · принтяием · данны" },
|
| 165 |
+
{ "code": "NO_STRUCTURE", "severity": "warning", "source": "rule", "message": "«Кондуит zero-version» — плоский список фрагментов без иерархии; тяжело реагировать построчно." }
|
| 166 |
+
],
|
| 167 |
+
"rewriteHints": [
|
| 168 |
+
"Поднять рамку в начало: решения принимаются всегда, сложности по обе стороны от момента — это и есть зацепка.",
|
| 169 |
+
"Явный персональный CTA под цель: «прочитай справочник → напиши свою версию кондуита (3–5 строк) → предложи одну правку схемы», с дедлайном.",
|
| 170 |
+
"Развести артефакт / задачу / логистику; кондуит структурировать в список с иерархией.",
|
| 171 |
+
"Дать ссылке рамку: что внутри и зачем открыть до того, как писать свою версию.",
|
| 172 |
+
"Вычитать опечатки — особенно когда просишь о вдумчивой работе."
|
| 173 |
+
]
|
| 174 |
+
}
|
| 175 |
+
</script>
|
| 176 |
+
|
| 177 |
+
<script type="application/json" id="sample-brief">
|
| 178 |
+
{
|
| 179 |
+
"status": "ok",
|
| 180 |
+
"inferred": {
|
| 181 |
+
"goal": "Активировать участников на 3 действия: прочитать материалы, написать свою версию кондуита, доработать схему",
|
| 182 |
+
"audience": "Рабочая группа по методологии принятия решений — коллеги в контексте, знают термины ЛПР / онтология / кондуит"
|
| 183 |
+
},
|
| 184 |
+
"gaps": [
|
| 185 |
+
{ "field": "audience", "reason": "Задана как «участники» без уровня/контекста; выведена из текста, но не подтверждена", "candidates": ["Коллеги глубоко в ко��тексте", "Новые участники, нужен ввод в тему", "Смешанная группа"] }
|
| 186 |
+
]
|
| 187 |
+
}
|
| 188 |
+
</script>
|
| 189 |
+
|
| 190 |
+
<script>
|
| 191 |
+
const DIM_LABELS = { hook:"Hook", clarity:"Clarity", audienceFit:"Audience fit", goalService:"Goal service", cta:"Call to action" };
|
| 192 |
+
const SEV_ORDER = { critical:0, warning:1, info:2 };
|
| 193 |
+
const SEV_CLASS = { critical:"crit", warning:"warn", info:"info" };
|
| 194 |
+
const SEV_RU = { critical:"critical", warning:"warning", info:"info" };
|
| 195 |
+
|
| 196 |
+
const $ = s => document.querySelector(s);
|
| 197 |
+
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c]));
|
| 198 |
+
|
| 199 |
+
const bandColor = pct => pct < 40 ? "var(--crit)" : pct < 70 ? "var(--warn)" : "var(--ok)";
|
| 200 |
+
const dimColor = s => s <= 2 ? "var(--crit)" : s === 3 ? "var(--warn)" : "var(--ok)";
|
| 201 |
+
|
| 202 |
+
function gauge(pct){
|
| 203 |
+
const r = 60, c = 2 * Math.PI * r, off = c * (1 - Math.max(0, Math.min(100, pct)) / 100);
|
| 204 |
+
const col = bandColor(pct);
|
| 205 |
+
return `<div class="gauge">
|
| 206 |
+
<svg width="138" height="138" viewBox="0 0 138 138">
|
| 207 |
+
<circle cx="69" cy="69" r="${r}" fill="none" stroke="#ece6d8" stroke-width="9"/>
|
| 208 |
+
<circle cx="69" cy="69" r="${r}" fill="none" stroke="${col}" stroke-width="9" stroke-linecap="round"
|
| 209 |
+
stroke-dasharray="${c.toFixed(1)}" stroke-dashoffset="${c.toFixed(1)}" transform="rotate(-90 69 69)"
|
| 210 |
+
style="animation:dash 1s cubic-bezier(.22,1,.36,1) forwards"/>
|
| 211 |
+
<style>@keyframes dash{to{stroke-dashoffset:${off.toFixed(1)}}}</style>
|
| 212 |
+
</svg>
|
| 213 |
+
<div class="num"><b style="color:${col}">${pct}</b><span>of 100</span></div>
|
| 214 |
+
</div>`;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
function renderAudit(d){
|
| 218 |
+
const ga = d.goalAlignment || {};
|
| 219 |
+
const dims = ga.dimensions || [];
|
| 220 |
+
const warns = (d.warnings || []).slice().sort((a,b)=>(SEV_ORDER[a.severity]??9)-(SEV_ORDER[b.severity]??9));
|
| 221 |
+
const counts = warns.reduce((m,w)=>(m[w.severity]=(m[w.severity]||0)+1,m),{});
|
| 222 |
+
const countStr = ["critical","warning","info"].filter(s=>counts[s]).map(s=>`${counts[s]} ${s}`).join(" · ");
|
| 223 |
+
|
| 224 |
+
let html = `<section class="sec"><div class="head">
|
| 225 |
+
${gauge(Number(ga.overall)||0)}
|
| 226 |
+
<div class="lead">
|
| 227 |
+
<p class="verdict">${esc(ga.summary || "")}</p>
|
| 228 |
+
${(ga.cappedBy||[]).length ? `<div class="capped"><span class="lbl">capped by:</span>${ga.cappedBy.map(c=>`<span class="tag crit mono">${esc(c)}</span>`).join("")}</div>`:""}
|
| 229 |
+
</div></div></section>`;
|
| 230 |
+
|
| 231 |
+
if (dims.length){
|
| 232 |
+
html += `<section class="sec"><div class="sec-h"><span>Goal dimensions</span><span class="wcount">score / 5</span></div>`;
|
| 233 |
+
html += dims.map(dm=>{
|
| 234 |
+
const s = Number(dm.score)||0, pct = (s/5)*100, col = dimColor(s);
|
| 235 |
+
return `<div class="dim">
|
| 236 |
+
<div class="name">${esc(DIM_LABELS[dm.key]||dm.key)}<span class="k">${esc(dm.key)}</span></div>
|
| 237 |
+
<div class="body">
|
| 238 |
+
<div class="bar"><div class="track"><div class="fill" style="width:${pct}%;background:${col}"></div></div><div class="sc">${s}/5</div></div>
|
| 239 |
+
<div class="rat">${esc(dm.rationale||"")}</div>
|
| 240 |
+
</div></div>`;
|
| 241 |
+
}).join("");
|
| 242 |
+
html += `</section>`;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
if (warns.length){
|
| 246 |
+
html += `<section class="sec"><div class="sec-h"><span>Warnings</span><span class="wcount">${esc(countStr)}</span></div>`;
|
| 247 |
+
html += warns.map((w,i)=>{
|
| 248 |
+
const cl = SEV_CLASS[w.severity]||"info";
|
| 249 |
+
return `<div class="w ${cl}" style="--i:${i}">
|
| 250 |
+
<div class="row">
|
| 251 |
+
<span class="sev ${cl}">${esc(SEV_RU[w.severity]||w.severity)}</span>
|
| 252 |
+
<span class="code">${esc(w.code||"")}</span>
|
| 253 |
+
${w.source?`<span class="src">${esc(w.source)}</span>`:""}
|
| 254 |
+
</div>
|
| 255 |
+
<div class="msg">${esc(w.message||"")}</div>
|
| 256 |
+
${w.evidence?`<div class="ev">${esc(w.evidence)}</div>`:""}
|
| 257 |
+
</div>`;
|
| 258 |
+
}).join("");
|
| 259 |
+
html += `</section>`;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
if ((d.rewriteHints||[]).length){
|
| 263 |
+
html += `<section class="sec"><div class="sec-h"><span>Rewrite hints</span></div>
|
| 264 |
+
<ol class="hints">${d.rewriteHints.map(h=>`<li>${esc(h)}</li>`).join("")}</ol></section>`;
|
| 265 |
+
}
|
| 266 |
+
return html;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
function renderBrief(d){
|
| 270 |
+
const ok = d.status === "ok";
|
| 271 |
+
let html = `<section class="sec">
|
| 272 |
+
<span class="status ${ok?"ok":"clar"}"><span class="dot"></span>${ok?"Brief accepted — audit uses inferred fields":"Needs clarification"}</span>
|
| 273 |
+
<div class="cards2" style="margin-top:16px">
|
| 274 |
+
<div class="infcard"><div class="t">Goal (inferred)</div><div class="v">${esc(d.inferred?.goal||"—")}</div></div>
|
| 275 |
+
<div class="infcard"><div class="t">Audience (inferred)</div><div class="v">${esc(d.inferred?.audience||"—")}</div></div>
|
| 276 |
+
</div></section>`;
|
| 277 |
+
if ((d.gaps||[]).length){
|
| 278 |
+
html += `<section class="sec"><div class="sec-h"><span>Gaps</span></div>`;
|
| 279 |
+
html += d.gaps.map(g=>`<div class="gap">
|
| 280 |
+
<span class="fld">${esc(g.field)}</span>
|
| 281 |
+
<div class="reason">${esc(g.reason||"")}</div>
|
| 282 |
+
<div class="chips">${(g.candidates||[]).map(c=>`<span class="chip">${esc(c)}</span>`).join("")}</div>
|
| 283 |
+
</div>`).join("");
|
| 284 |
+
html += `</section>`;
|
| 285 |
+
}
|
| 286 |
+
return html;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
function render(){
|
| 290 |
+
const err = $("#err"); err.style.display = "none";
|
| 291 |
+
let data;
|
| 292 |
+
try { data = JSON.parse($("#src").value); }
|
| 293 |
+
catch(e){ err.textContent = "Invalid JSON: " + e.message; err.style.display = "block"; $("#out").innerHTML=""; return; }
|
| 294 |
+
const out = $("#out");
|
| 295 |
+
if (data && data.goalAlignment) out.innerHTML = renderAudit(data);
|
| 296 |
+
else if (data && (data.status || data.gaps || data.inferred)) out.innerHTML = renderBrief(data);
|
| 297 |
+
else { err.textContent = "Not AuditReport or BriefCheck (missing goalAlignment / status)."; err.style.display="block"; out.innerHTML=""; }
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
function loadSample(which){
|
| 301 |
+
$("#src").value = $("#sample-"+which).textContent.trim();
|
| 302 |
+
render();
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
window.renderAuditPayload = function(data) {
|
| 306 |
+
$("#src").value = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
| 307 |
+
render();
|
| 308 |
+
};
|
| 309 |
+
|
| 310 |
+
$("#render").addEventListener("click", render);
|
| 311 |
+
$("#clear").addEventListener("click", ()=>{ $("#src").value=""; $("#out").innerHTML=""; $("#err").style.display="none"; });
|
| 312 |
+
document.querySelectorAll("[data-sample]").forEach(b=>b.addEventListener("click",()=>loadSample(b.dataset.sample)));
|
| 313 |
+
|
| 314 |
+
if (new URLSearchParams(location.search).get("embed") === "1") {
|
| 315 |
+
document.querySelector(".io").style.display = "none";
|
| 316 |
+
} else {
|
| 317 |
+
loadSample("audit");
|
| 318 |
+
}
|
| 319 |
+
</script>
|
| 320 |
+
</body>
|
| 321 |
+
</html>
|