Compare commits

..
Author SHA1 Message Date
Typosaurus a2a50e0144 ticket #65 attempt 1 2026-07-19 19:00:51 +00:00
Typosaurus c119e39813 ticket #65 attempt 1 2026-07-19 18:35:37 +00:00
Typosaurus 7cc39076bf ticket #65 attempt 1 2026-07-19 18:16:23 +00:00
25 changed files with 100 additions and 86 deletions
File diff suppressed because one or more lines are too long
+4
View File
@@ -39,6 +39,10 @@ NGINX_MAX_BODY_SIZE=50m
NGINX_CACHE_ENABLED=false NGINX_CACHE_ENABLED=false
NGINX_CACHE_MAX_SIZE=1g NGINX_CACHE_MAX_SIZE=1g
# Max length of comment text (applied server-side on write and client-side on input).
# Change at any time via the env var; the DB has no length constraint.
DEVPLACE_COMMENT_MAX_LENGTH=1000
# Run the app container as this host user so shared files keep dev ownership. # Run the app container as this host user so shared files keep dev ownership.
DEVPLACE_UID=1000 DEVPLACE_UID=1000
DEVPLACE_GID=1000 DEVPLACE_GID=1000
+1
View File
@@ -169,6 +169,7 @@ The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) | | `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) |
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) | | `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) |
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin before an online user drops to offline (hysteresis): online at the timeout, offline only after timeout + this. Prevents online/offline flicker for users hovering at the boundary | | `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin before an online user drops to offline (hysteresis): online at the timeout, offline only after timeout + this. Prevents online/offline flicker for users hovering at the boundary |
| `DEVPLACE_COMMENT_MAX_LENGTH` | `1000` | Maximum character length for comment body text. Applied server-side via Pydantic validation and client-side via `maxLength` on textareas. The database has no column-length constraint, so this can be changed at any time via the environment variable (requires restart) |
### Runtime settings ### Runtime settings
+1
View File
@@ -68,6 +68,7 @@ INTERNAL_GATEWAY_URL = f"{INTERNAL_BASE_URL}/openai/v1/chat/completions"
INTERNAL_EMBED_URL = f"{INTERNAL_BASE_URL}/openai/v1/embeddings" INTERNAL_EMBED_URL = f"{INTERNAL_BASE_URL}/openai/v1/embeddings"
INTERNAL_MODEL = "molodetz" INTERNAL_MODEL = "molodetz"
INTERNAL_EMBED_MODEL = "molodetz~embed" INTERNAL_EMBED_MODEL = "molodetz~embed"
COMMENT_MAX_LENGTH = int(environ.get("DEVPLACE_COMMENT_MAX_LENGTH", "1000"))
DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing" DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing"
DEFAULT_MODIFIER_PROMPT = ( DEFAULT_MODIFIER_PROMPT = (
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`" "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`"
+3 -2
View File
@@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
from devplacepy.config import COMMENT_MAX_LENGTH
from .._shared import COMMENT_TARGETS, GIST_LANGUAGES, PROJECT_TYPES, endpoint, field from .._shared import COMMENT_TARGETS, GIST_LANGUAGES, PROJECT_TYPES, endpoint, field
from devplacepy.constants import TOPICS from devplacepy.constants import TOPICS
@@ -229,7 +230,7 @@ four ways to sign requests.
"textarea", "textarea",
True, True,
"Nice work.", "Nice work.",
"Body, 3-1000 characters.", f"Body, 3-{COMMENT_MAX_LENGTH} characters.",
), ),
field( field(
"target_uid", "target_uid",
@@ -290,7 +291,7 @@ four ways to sign requests.
"textarea", "textarea",
True, True,
"Edited body.", "Edited body.",
"New body, 3-1000 characters.", f"New body, 3-{COMMENT_MAX_LENGTH} characters.",
), ),
], ],
sample_response={ sample_response={
+3 -1
View File
@@ -1,5 +1,7 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
from devplacepy.config import COMMENT_MAX_LENGTH
ROLE_LABELS = {"public": "Public", "user": "Member"} ROLE_LABELS = {"public": "Public", "user": "Member"}
@@ -237,7 +239,7 @@ DEVRANT_GROUPS = {
encoding="form", encoding="form",
params=[ params=[
field("rant_id", "path", type="int", required=True, example="1", description="Rant id."), field("rant_id", "path", type="int", required=True, example="1", description="Rant id."),
field("comment", "body", type="textarea", required=True, example="Great rant!", description="Comment text, 1-1000 chars."), field("comment", "body", type="textarea", required=True, example="Great rant!", description=f"Comment text, 1-{COMMENT_MAX_LENGTH} chars."),
], ],
sample_response={"success": True}, sample_response={"success": True},
), ),
+3 -3
View File
@@ -6,7 +6,7 @@ from datetime import datetime
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.constants import TOPICS, REACTION_EMOJI from devplacepy.constants import TOPICS, REACTION_EMOJI
from devplacepy.config import DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT from devplacepy.config import COMMENT_MAX_LENGTH, DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT
def normalize_european_date(value): def normalize_european_date(value):
@@ -157,7 +157,7 @@ class PostEditForm(BaseModel):
class CommentForm(BaseModel): class CommentForm(BaseModel):
content: str = Field(min_length=3, max_length=1000) content: str = Field(min_length=3, max_length=COMMENT_MAX_LENGTH)
target_uid: str = Field(default="", max_length=36) target_uid: str = Field(default="", max_length=36)
post_uid: str = Field(default="", max_length=36) post_uid: str = Field(default="", max_length=36)
target_type: Literal["post", "project", "news", "issue", "gist"] = "post" target_type: Literal["post", "project", "news", "issue", "gist"] = "post"
@@ -172,7 +172,7 @@ class CommentForm(BaseModel):
class CommentEditForm(BaseModel): class CommentEditForm(BaseModel):
content: str = Field(min_length=3, max_length=1000) content: str = Field(min_length=3, max_length=COMMENT_MAX_LENGTH)
class ProjectForm(BaseModel): class ProjectForm(BaseModel):
+2 -1
View File
@@ -69,7 +69,8 @@ async def edit_comment(request: Request, comment_id: str):
if not is_owner(comment, user): if not is_owner(comment, user):
return dr_error("You can only edit your own comments.", fail_reason="not_owner") return dr_error("You can only edit your own comments.", fail_reason="not_owner")
text = (params.get("comment") or "").strip() text = (params.get("comment") or "").strip()
if len(text) < 1 or len(text) > 1000: from devplacepy.config import COMMENT_MAX_LENGTH
if len(text) < 1 or len(text) > COMMENT_MAX_LENGTH:
return dr_error("Invalid comment length.", fail_reason="length") return dr_error("Invalid comment length.", fail_reason="length")
get_table("comments").update( get_table("comments").update(
{ {
+2 -1
View File
@@ -227,7 +227,8 @@ async def comment_rant(request: Request, rant_id: str):
text = (params.get("comment") or "").strip() text = (params.get("comment") or "").strip()
if len(text) < 1: if len(text) < 1:
return dr_error("Your comment is too short.") return dr_error("Your comment is too short.")
if len(text) > 1000: from devplacepy.config import COMMENT_MAX_LENGTH
if len(text) > COMMENT_MAX_LENGTH:
return dr_error("Your comment is too long.") return dr_error("Your comment is too long.")
create_comment_record(request, user, "post", post["uid"], text) create_comment_record(request, user, "post", post["uid"], text)
return dr_ok() return dr_ok()
+1 -4
View File
@@ -227,8 +227,7 @@ def _session_context(request: Request, uid: str, job: dict, session: dict) -> di
user = get_current_user(request) user = get_current_user(request)
viewer_is_admin = is_admin(user) viewer_is_admin = is_admin(user)
done = bool(report) or job.get("status") == queue.DONE done = bool(report) or job.get("status") == queue.DONE
cost_usd = report.get("cost_usd", 0.0) if report else 0.0 return {
ctx = {
"uid": uid, "uid": uid,
"status": queue.DONE if done else job.get("status", ""), "status": queue.DONE if done else job.get("status", ""),
"query": report.get("query") or session.get("query"), "query": report.get("query") or session.get("query"),
@@ -248,7 +247,6 @@ def _session_context(request: Request, uid: str, job: dict, session: dict) -> di
"export_md_url": f"/tools/deepsearch/{uid}/export.md" if done else None, "export_md_url": f"/tools/deepsearch/{uid}/export.md" if done else None,
"export_json_url": f"/tools/deepsearch/{uid}/export.json" if done else None, "export_json_url": f"/tools/deepsearch/{uid}/export.json" if done else None,
"export_pdf_url": f"/tools/deepsearch/{uid}/export.pdf" if done else None, "export_pdf_url": f"/tools/deepsearch/{uid}/export.pdf" if done else None,
"cost_usd": cost_usd if viewer_is_admin else None,
"viewer_is_admin": viewer_is_admin, "viewer_is_admin": viewer_is_admin,
"viewer_owns": _owns(request, session), "viewer_owns": _owns(request, session),
"created_at": session.get("created_at"), "created_at": session.get("created_at"),
@@ -257,7 +255,6 @@ def _session_context(request: Request, uid: str, job: dict, session: dict) -> di
"user": user, "user": user,
"meta_robots": "noindex,nofollow", "meta_robots": "noindex,nofollow",
} }
return ctx
@router.get("/{uid}/session") @router.get("/{uid}/session")
async def deepsearch_session(request: Request, uid: str): async def deepsearch_session(request: Request, uid: str):
-1
View File
@@ -136,7 +136,6 @@ class DeepsearchSessionOut(_Out):
export_md_url: Optional[str] = None export_md_url: Optional[str] = None
export_json_url: Optional[str] = None export_json_url: Optional[str] = None
export_pdf_url: Optional[str] = None export_pdf_url: Optional[str] = None
cost_usd: Optional[float] = None
viewer_is_admin: bool = False viewer_is_admin: bool = False
viewer_owns: bool = False viewer_owns: bool = False
created_at: Optional[str] = None created_at: Optional[str] = None
+1 -1
View File
@@ -72,7 +72,7 @@ MENTION_POLL_SECONDS = int(os.environ.get("MENTION_POLL_SECONDS", "30"))
DM_POLL_SECONDS = int(os.environ.get("DM_POLL_SECONDS", "10")) DM_POLL_SECONDS = int(os.environ.get("DM_POLL_SECONDS", "10"))
BOT_USERNAME = os.environ.get("BOT_USERNAME", "") BOT_USERNAME = os.environ.get("BOT_USERNAME", "")
COMMENT_CHAR_LIMIT = 1000 COMMENT_CHAR_LIMIT = int(os.environ.get("DEVPLACE_COMMENT_MAX_LENGTH", "1000"))
MESSAGE_CHAR_LIMIT = 2000 MESSAGE_CHAR_LIMIT = 2000
PART_SUFFIX_RESERVE = 12 PART_SUFFIX_RESERVE = 12
PART_DELIVERY_DELAY = 0.5 PART_DELIVERY_DELAY = 0.5
+1 -5
View File
@@ -7,7 +7,6 @@ import time
from devplacepy import stealth from devplacepy import stealth
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
from devplacepy.services.openai_gateway.usage import parse_usage_headers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -42,10 +41,7 @@ async def request_completion(
if response.status_code >= 400: if response.status_code >= 400:
raise RuntimeError(f"chat gateway returned {response.status_code}") raise RuntimeError(f"chat gateway returned {response.status_code}")
data: dict = response.json() data: dict = response.json()
cost_info = parse_usage_headers(response.headers) or {} return data, data.get("usage") or {}, elapsed_ms
usage = data.get("usage") or {}
usage["cost_usd"] = cost_info.get("cost_usd", 0.0)
return data, usage, elapsed_ms
async def complete_chat( async def complete_chat(
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from devplacepy.config import COMMENT_MAX_LENGTH
from ..spec import Action from ..spec import Action
from ._shared import ATTACHMENTS, TARGET_TYPE, body, confirm, path from ._shared import ATTACHMENTS, TARGET_TYPE, body, confirm, path
@@ -28,7 +29,7 @@ COMMENTS_ACTIONS: tuple[Action, ...] = (
summary="Edit the body of one of your own comments", summary="Edit the body of one of your own comments",
params=( params=(
path("comment_uid", "Uid of the comment."), path("comment_uid", "Uid of the comment."),
body("content", "New comment body, 3-1000 characters.", required=True), body("content", f"New comment body, 3-{COMMENT_MAX_LENGTH} characters.", required=True),
), ),
), ),
Action( Action(
@@ -5,11 +5,10 @@ from __future__ import annotations
import json import json
import logging import logging
import re import re
from typing import Callable, Tuple from typing import Callable
from devplacepy import stealth from devplacepy import stealth
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
from devplacepy.services.openai_gateway.usage import parse_usage_headers
from .phases import PHASE_PLANNING from .phases import PHASE_PLANNING
@@ -62,7 +61,7 @@ def _noop(frame: dict) -> None:
async def plan_queries( async def plan_queries(
query: str, api_key: str, emit: Callable[[dict], None] = _noop query: str, api_key: str, emit: Callable[[dict], None] = _noop
) -> Tuple[list[str], float]: ) -> list[str]:
emit( emit(
{ {
"type": "substep", "type": "substep",
@@ -90,8 +89,6 @@ async def plan_queries(
) )
if response.status_code >= 400: if response.status_code >= 400:
raise RuntimeError(f"planner gateway returned {response.status_code}") raise RuntimeError(f"planner gateway returned {response.status_code}")
cost_info = parse_usage_headers(response.headers) or {}
planner_cost = cost_info.get("cost_usd", 0.0)
data = response.json() data = response.json()
content = ( content = (
(data.get("choices") or [{}])[0].get("message", {}).get("content") or "" (data.get("choices") or [{}])[0].get("message", {}).get("content") or ""
@@ -105,7 +102,7 @@ async def plan_queries(
"message": f"Planned {len(parsed)} search angles", "message": f"Planned {len(parsed)} search angles",
} }
) )
return parsed, planner_cost return parsed
except Exception as exc: except Exception as exc:
logger.warning("deepsearch query planner failed, using fallback: %s", exc) logger.warning("deepsearch query planner failed, using fallback: %s", exc)
fallback = _fallback(query) fallback = _fallback(query)
@@ -116,4 +113,4 @@ async def plan_queries(
"message": f"Planner unavailable, using {len(fallback)} heuristic angles", "message": f"Planner unavailable, using {len(fallback)} heuristic angles",
} }
) )
return fallback, 0.0 return fallback
@@ -40,7 +40,6 @@ class Orchestration:
source_diversity: float = 0.0 source_diversity: float = 0.0
score: int = 0 score: int = 0
synthesis: str = "agents" synthesis: str = "agents"
total_cost_usd: float = 0.0
def _domain(url: str) -> str: def _domain(url: str) -> str:
@@ -166,7 +165,6 @@ async def _complete(
"tokens_in": int(raw_usage.get("prompt_tokens") or prompt_chars // 4), "tokens_in": int(raw_usage.get("prompt_tokens") or prompt_chars // 4),
"tokens_out": int(raw_usage.get("completion_tokens") or len(text) // 4), "tokens_out": int(raw_usage.get("completion_tokens") or len(text) // 4),
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"cost_usd": float(raw_usage.get("cost_usd", 0.0)),
} }
return text, usage return text, usage
@@ -298,7 +296,6 @@ def _agent_done(emit: Callable[[dict], None], agent: str, usage: dict) -> None:
"elapsed_ms": usage.get("elapsed_ms", 0), "elapsed_ms": usage.get("elapsed_ms", 0),
"tokens_in": usage.get("tokens_in", 0), "tokens_in": usage.get("tokens_in", 0),
"tokens_out": usage.get("tokens_out", 0), "tokens_out": usage.get("tokens_out", 0),
"cost_usd": usage.get("cost_usd", 0.0),
} }
) )
@@ -349,7 +346,6 @@ async def orchestrate(
emit: Callable[[dict], None], emit: Callable[[dict], None],
store=None, store=None,
queries: list[str] | None = None, queries: list[str] | None = None,
planner_cost_usd: float = 0.0,
) -> Orchestration: ) -> Orchestration:
diversity = source_diversity(pages) diversity = source_diversity(pages)
if not pages: if not pages:
@@ -422,12 +418,6 @@ async def orchestrate(
score = int( score = int(
min(SCORE_MAX, (confidence * 0.5 + diversity * 0.3 + coverage * 0.2) * SCORE_MAX) min(SCORE_MAX, (confidence * 0.5 + diversity * 0.3 + coverage * 0.2) * SCORE_MAX)
) )
total_cost = (
planner_cost_usd
+ float(summary_usage.get("cost_usd", 0.0))
+ float(findings_usage.get("cost_usd", 0.0))
+ float(linker_usage.get("cost_usd", 0.0))
)
return Orchestration( return Orchestration(
summary=summary, summary=summary,
findings=findings, findings=findings,
@@ -435,7 +425,6 @@ async def orchestrate(
source_diversity=diversity, source_diversity=diversity,
score=score, score=score,
synthesis="agents", synthesis="agents",
total_cost_usd=total_cost,
) )
except Exception as exc: except Exception as exc:
logger.warning("deepsearch orchestration failed, using heuristic: %s", exc) logger.warning("deepsearch orchestration failed, using heuristic: %s", exc)
@@ -165,7 +165,7 @@ async def _run(payload: dict, output_dir: Path) -> dict:
should_stop = _make_stop(output_dir) should_stop = _make_stop(output_dir)
_stage("planning", "Planning research queries", PHASE_PLANNING) _stage("planning", "Planning research queries", PHASE_PLANNING)
queries, planner_cost = await plan_queries(query, api_key, _emit) queries = await plan_queries(query, api_key, _emit)
_emit({"type": "queries", "queries": queries}) _emit({"type": "queries", "queries": queries})
_stage("searching", "Searching the web", PHASE_SEARCHING) _stage("searching", "Searching the web", PHASE_SEARCHING)
@@ -203,8 +203,7 @@ async def _run(payload: dict, output_dir: Path) -> dict:
_stage("analysis", "Running research agents", PHASE_ANALYSIS) _stage("analysis", "Running research agents", PHASE_ANALYSIS)
result = await orchestrate( result = await orchestrate(
query, outcome.pages, api_key, _emit, store=store, queries=queries, query, outcome.pages, api_key, _emit, store=store, queries=queries
planner_cost_usd=planner_cost,
) )
_stage("synthesis", "Compiling cited report", PHASE_SYNTHESIS) _stage("synthesis", "Compiling cited report", PHASE_SYNTHESIS)
@@ -229,7 +228,6 @@ async def _run(payload: dict, output_dir: Path) -> dict:
"chunk_count": chunk_count, "chunk_count": chunk_count,
"embed_backend": embed_backend, "embed_backend": embed_backend,
"collection": collection, "collection": collection,
"cost_usd": result.total_cost_usd,
} }
(output_dir / "report.json").write_text( (output_dir / "report.json").write_text(
json.dumps(report, ensure_ascii=False), encoding="utf-8" json.dumps(report, ensure_ascii=False), encoding="utf-8"
@@ -246,7 +244,6 @@ async def _run(payload: dict, output_dir: Path) -> dict:
"synthesis": report["synthesis"], "synthesis": report["synthesis"],
"page_count": report["page_count"], "page_count": report["page_count"],
"chunk_count": report["chunk_count"], "chunk_count": report["chunk_count"],
"cost_usd": report["cost_usd"],
} }
) )
return report return report
+2
View File
@@ -83,6 +83,8 @@ export class CommentManager {
textarea.className = "emoji-picker-target"; textarea.className = "emoji-picker-target";
textarea.value = text.dataset.raw || text.textContent; textarea.value = text.dataset.raw || text.textContent;
textarea.rows = 3; textarea.rows = 3;
const ref = document.querySelector("[data-comment-maxlength]");
textarea.maxLength = ref ? parseInt(ref.dataset.commentMaxlength, 10) : 1000;
form.appendChild(textarea); form.appendChild(textarea);
const actions = document.createElement("div"); const actions = document.createElement("div");
+2 -2
View File
@@ -1,9 +1,9 @@
{% if user %} {% if user %}
<form class="comment-form" method="POST" action="/comments/create"> <form class="comment-form" method="POST" action="/comments/create" data-comment-maxlength="{{ comment_max_length }}">
<input type="hidden" name="target_uid" value="{{ _comment_target_uid }}"> <input type="hidden" name="target_uid" value="{{ _comment_target_uid }}">
<input type="hidden" name="target_type" value="{{ _comment_target_type }}"> <input type="hidden" name="target_type" value="{{ _comment_target_type }}">
{% set _user = user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %} {% set _user = user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<textarea name="content" placeholder="Your opinion goes here..." required aria-required="true" aria-label="Comment" maxlength="1000" class="emoji-picker-target" data-mention></textarea> <textarea name="content" placeholder="Your opinion goes here..." required aria-required="true" aria-label="Comment" maxlength="{{ comment_max_length }}" class="emoji-picker-target" data-mention></textarea>
<div class="comment-form-actions"> <div class="comment-form-actions">
<dp-upload multiple <dp-upload multiple
max-size="{{ max_upload_size_mb() }}" max-size="{{ max_upload_size_mb() }}"
@@ -16,9 +16,6 @@
<span class="ds-metric"><strong>{{ source_diversity }}</strong> diversity</span> <span class="ds-metric"><strong>{{ source_diversity }}</strong> diversity</span>
<span class="ds-metric"><strong>{{ page_count }}</strong> sources</span> <span class="ds-metric"><strong>{{ page_count }}</strong> sources</span>
<span class="ds-metric"><strong>{{ chunk_count }}</strong> chunks</span> <span class="ds-metric"><strong>{{ chunk_count }}</strong> chunks</span>
{% if viewer_is_admin and cost_usd is not none %}
<span class="ds-metric"><strong>${{ "%.6f"|format(cost_usd) }}</strong> cost</span>
{% endif %}
</div> </div>
{% if export_md_url %} {% if export_md_url %}
<div class="ds-exports"> <div class="ds-exports">
+2 -1
View File
@@ -6,7 +6,7 @@ import jinja2
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from markupsafe import Markup, escape from markupsafe import Markup, escape
from devplacepy.cache import TTLCache from devplacepy.cache import TTLCache
from devplacepy.config import STATIC_VERSION, TEMPLATES_DIR, TEMPLATE_AUTO_RELOAD, PRESENCE_TIMEOUT_SECONDS from devplacepy.config import COMMENT_MAX_LENGTH, STATIC_VERSION, TEMPLATES_DIR, TEMPLATE_AUTO_RELOAD, PRESENCE_TIMEOUT_SECONDS
from devplacepy.constants import TOPICS, REACTION_EMOJI from devplacepy.constants import TOPICS, REACTION_EMOJI
from devplacepy.database import get_int_setting, get_setting, get_table from devplacepy.database import get_int_setting, get_setting, get_table
from devplacepy.avatar import avatar_url, avatar_seed from devplacepy.avatar import avatar_url, avatar_seed
@@ -67,6 +67,7 @@ templates.env.globals["is_self"] = is_self
templates.env.globals["guest_disabled"] = guest_disabled templates.env.globals["guest_disabled"] = guest_disabled
templates.env.globals["is_online"] = presence.is_online templates.env.globals["is_online"] = presence.is_online
templates.env.globals["presence_timeout"] = PRESENCE_TIMEOUT_SECONDS templates.env.globals["presence_timeout"] = PRESENCE_TIMEOUT_SECONDS
templates.env.globals["comment_max_length"] = COMMENT_MAX_LENGTH
from devplacepy.docs_devrant import devrant_endpoints from devplacepy.docs_devrant import devrant_endpoints
+28 -8
View File
@@ -1,8 +1,28 @@
2026-07-19T17:31:36 INFO logging initialised at /workspace/repo/dpc.log 2026-07-19T08:11:05 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T17:31:36 DEBUG model=molodetz-pro fps=30 2026-07-19T08:11:05 DEBUG model=molodetz-pro fps=30
2026-07-19T17:31:36 INFO read task from file: /workspace/prompts/research-10.txt 2026-07-19T08:11:05 INFO read task from file: /workspace/prompts/research-1.txt
2026-07-19T17:31:36 INFO settings merged: model=<default> allow=0 deny=0 ask=0 2026-07-19T08:11:05 INFO settings merged: model=<default> allow=0 deny=0 ask=0
2026-07-19T19:05:24 INFO logging initialised at /workspace/repo/dpc.log 2026-07-19T15:03:09 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T19:05:24 DEBUG model=molodetz-pro fps=30 2026-07-19T15:03:09 DEBUG model=molodetz-pro fps=30
2026-07-19T19:05:24 INFO read task from file: /workspace/prompts/execution-1.txt 2026-07-19T15:03:09 INFO read task from file: /workspace/prompts/research-2.txt
2026-07-19T19:05:24 INFO settings merged: model=<default> allow=0 deny=0 ask=0 2026-07-19T15:03:09 INFO settings merged: model=<default> allow=0 deny=0 ask=0
2026-07-19T15:40:16 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T15:40:16 DEBUG model=molodetz-pro fps=30
2026-07-19T15:40:16 INFO read task from file: /workspace/prompts/research-3.txt
2026-07-19T15:40:16 INFO settings merged: model=<default> allow=0 deny=0 ask=0
2026-07-19T17:35:33 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T17:35:33 DEBUG model=molodetz-pro fps=30
2026-07-19T17:35:33 INFO read task from file: /workspace/prompts/research-4.txt
2026-07-19T17:35:33 INFO settings merged: model=<default> allow=0 deny=0 ask=0
2026-07-19T18:11:47 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T18:11:47 DEBUG model=molodetz-pro fps=30
2026-07-19T18:11:47 INFO read task from file: /workspace/prompts/execution-1.txt
2026-07-19T18:11:47 INFO settings merged: model=<default> allow=0 deny=0 ask=0
2026-07-19T18:31:31 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T18:31:31 DEBUG model=molodetz-pro fps=30
2026-07-19T18:31:31 INFO read task from file: /workspace/prompts/execution-2.txt
2026-07-19T18:31:31 INFO settings merged: model=<default> allow=0 deny=0 ask=0
2026-07-19T18:58:51 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T18:58:51 DEBUG model=molodetz-pro fps=30
2026-07-19T18:58:51 INFO read task from file: /workspace/prompts/execution-3.txt
2026-07-19T18:58:51 INFO settings merged: model=<default> allow=0 deny=0 ask=0
+19
View File
@@ -345,3 +345,22 @@ def test_comment_links_multiple_attachments(app_server):
assert u1 in r.text and u2 in r.text, ( assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed on the comment" "both attachments must be linked and displayed on the comment"
) )
def test_create_comment_too_long_rejected(app_server):
from devplacepy.config import COMMENT_MAX_LENGTH
s, _ = _session_comments()
post_uid = _create_post_comments(s, f"cmt-long-{int(time.time() * 1000)}")
r = s.post(
f"{BASE_URL}/comments/create",
data={
"content": "x" * (COMMENT_MAX_LENGTH + 1),
"target_type": "post",
"post_uid": post_uid,
"target_uid": post_uid,
},
allow_redirects=False,
)
assert r.status_code in (400, 422), r.text[:300]
refresh_snapshot()
assert get_table("comments").find_one(target_uid=post_uid) is None
+16
View File
@@ -129,3 +129,19 @@ def test_edit_comment_too_short_rejected(app_server):
assert r.status_code in (400, 422) assert r.status_code in (400, 422)
refresh_snapshot() refresh_snapshot()
assert get_table("comments").find_one(uid=comment["uid"])["content"] == "Long enough body" assert get_table("comments").find_one(uid=comment["uid"])["content"] == "Long enough body"
def test_edit_comment_too_long_rejected(app_server):
from devplacepy.config import COMMENT_MAX_LENGTH
s, _ = _session_edit()
post_uid = _create_post_edit(s, f"edit-long-{int(time.time() * 1000)}")
comment = _create_comment_edit(s, post_uid, "Normal body length")
r = s.post(
f"{BASE_URL}/comments/edit/{comment['uid']}",
headers=JSON_edit,
data={"content": "x" * (COMMENT_MAX_LENGTH + 1)},
allow_redirects=False,
)
assert r.status_code in (400, 422), r.text[:300]
refresh_snapshot()
assert get_table("comments").find_one(uid=comment["uid"])["content"] == "Normal body length"
-28
View File
@@ -39,7 +39,6 @@ def _seed_done_session(owner_id="ds-session-owner"):
"source_diversity": 0.5, "source_diversity": 0.5,
"page_count": 3, "page_count": 3,
"chunk_count": 12, "chunk_count": 12,
"cost_usd": 0.123456,
} }
result = { result = {
"query": "the question", "query": "the question",
@@ -48,7 +47,6 @@ def _seed_done_session(owner_id="ds-session-owner"):
"source_diversity": 0.5, "source_diversity": 0.5,
"page_count": 3, "page_count": 3,
"chunk_count": 12, "chunk_count": 12,
"cost_usd": 0.123456,
"report": report, "report": report,
} }
get_table("jobs").update( get_table("jobs").update(
@@ -99,7 +97,6 @@ def test_session_json_shape(app_server):
assert body["sources"] assert body["sources"]
assert "viewer_is_admin" in body assert "viewer_is_admin" in body
assert "viewer_owns" in body assert "viewer_owns" in body
assert body.get("cost_usd") is None # guest user, not admin
finally: finally:
_clear() _clear()
@@ -111,7 +108,6 @@ def test_session_html_renders_without_jinja_global_collision(app_server):
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert "the question" in r.text assert "the question" in r.text
assert "dp-deepsearch-chat" in r.text assert "dp-deepsearch-chat" in r.text
assert "0.123456" not in r.text # guest user, cost hidden
finally: finally:
_clear() _clear()
@@ -146,7 +142,6 @@ def test_session_reads_disk_report_before_result_commit(app_server):
"synthesis": "agents", "synthesis": "agents",
"page_count": 12, "page_count": 12,
"chunk_count": 61, "chunk_count": 61,
"cost_usd": 0.123456,
} }
session_dir = DEEPSEARCH_DIR / uid session_dir = DEEPSEARCH_DIR / uid
session_dir.mkdir(parents=True, exist_ok=True) session_dir.mkdir(parents=True, exist_ok=True)
@@ -208,26 +203,3 @@ def test_export_json(app_server):
def test_export_unknown_uid_404(app_server): def test_export_unknown_uid_404(app_server):
r = requests.get(f"{BASE_URL}/tools/deepsearch/nope/export.md") r = requests.get(f"{BASE_URL}/tools/deepsearch/nope/export.md")
assert r.status_code == 404 assert r.status_code == 404
def test_session_admin_sees_cost(app_server, seeded_db):
try:
uid = _seed_done_session("alice_test")
s = requests.Session()
creds = seeded_db["alice"]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
r = s.get(f"{BASE_URL}/tools/deepsearch/{uid}/session", headers=_json_headers())
assert r.status_code == 200, r.text
body = r.json()
assert body.get("cost_usd") == 0.123456
html = s.get(f"{BASE_URL}/tools/deepsearch/{uid}/session")
assert html.status_code == 200, html.text
assert "0.123456" in html.text
assert "cost" in html.text
finally:
_clear()