fix: tier bypass, draft body persistence, canDraftLlm cleanup, limit cap
- CRITICAL: Remove X-CF-Tier header trust from _get_effective_tier; use
Heimdall in cloud mode and APP_TIER env var in single-tenant only
- HIGH: Add update_message_body helper + PUT /api/messages/{id} endpoint;
updateMessageBody store action; approveDraft now persists edits to DB
before calling approve so history always shows the final approved text
- Cleanup: Remove dead canDraftLlm ref, checkLlmAvailable function, and
v-else-if Enable LLM drafts link; show Draft reply button unconditionally
- MEDIUM: Cap GET /api/messages limit param with Query(ge=1, le=1000)
- Test: Update test_draft_without_llm_returns_402 to patch effective_tier
instead of sending X-CF-Tier header
This commit is contained in:
parent
6812e3f9ef
commit
91e2faf5d0
5 changed files with 55 additions and 42 deletions
30
dev-api.py
30
dev-api.py
|
|
@ -26,7 +26,7 @@ import yaml
|
|||
from bs4 import BeautifulSoup
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response, UploadFile
|
||||
from fastapi import FastAPI, HTTPException, Query, Request, Response, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -4195,6 +4195,10 @@ class MessageCreateBody(BaseModel):
|
|||
logged_at: Optional[str] = None
|
||||
|
||||
|
||||
class MessageUpdateBody(BaseModel):
|
||||
body: str
|
||||
|
||||
|
||||
class TemplateCreateBody(BaseModel):
|
||||
title: str
|
||||
category: str = "custom"
|
||||
|
|
@ -4216,7 +4220,7 @@ def get_messages(
|
|||
job_id: Optional[int] = None,
|
||||
type: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
):
|
||||
from scripts.messaging import list_messages
|
||||
return list_messages(
|
||||
|
|
@ -4241,6 +4245,15 @@ def del_message(message_id: int):
|
|||
raise HTTPException(404, "message not found")
|
||||
|
||||
|
||||
@app.put("/api/messages/{message_id}")
|
||||
def put_message(message_id: int, body: MessageUpdateBody):
|
||||
from scripts.messaging import update_message_body
|
||||
try:
|
||||
return update_message_body(Path(_request_db.get() or DB_PATH), message_id, body.body)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "message not found")
|
||||
|
||||
|
||||
@app.get("/api/message-templates")
|
||||
def get_templates():
|
||||
from scripts.messaging import list_templates
|
||||
|
|
@ -4282,24 +4295,23 @@ def del_template(template_id: int):
|
|||
|
||||
# ── LLM Reply Draft (BSL 1.1) ─────────────────────────────────────────────────
|
||||
|
||||
def _get_effective_tier(request: Request) -> str:
|
||||
"""Resolve effective tier from request header or environment."""
|
||||
header_tier = request.headers.get("X-CF-Tier")
|
||||
if header_tier:
|
||||
return header_tier
|
||||
def _get_effective_tier() -> str:
|
||||
"""Resolve effective tier: Heimdall in cloud mode, APP_TIER env var in single-tenant."""
|
||||
if _CLOUD_MODE:
|
||||
return _resolve_cloud_tier()
|
||||
from app.wizard.tiers import effective_tier
|
||||
return effective_tier()
|
||||
|
||||
|
||||
@app.post("/api/contacts/{contact_id}/draft-reply")
|
||||
def draft_reply(contact_id: int, request: Request):
|
||||
def draft_reply(contact_id: int):
|
||||
"""Generate an LLM draft reply for an inbound job_contacts row. Tier-gated."""
|
||||
from app.wizard.tiers import can_use, has_configured_llm
|
||||
from scripts.messaging import create_message
|
||||
from scripts.llm_reply_draft import generate_draft_reply
|
||||
|
||||
db_path = Path(_request_db.get() or DB_PATH)
|
||||
tier = _get_effective_tier(request)
|
||||
tier = _get_effective_tier()
|
||||
if not can_use(tier, "llm_reply_draft", has_byok=has_configured_llm()):
|
||||
raise HTTPException(402, detail={"error": "tier_required", "min_tier": "free+byok"})
|
||||
|
||||
|
|
|
|||
|
|
@ -268,3 +268,18 @@ def delete_template(db_path: Path, template_id: int) -> None:
|
|||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def update_message_body(db_path: Path, message_id: int, body: str) -> dict:
|
||||
"""Update the body text of a draft message before approval. Returns updated row."""
|
||||
con = _connect(db_path)
|
||||
try:
|
||||
row = con.execute("SELECT id FROM messages WHERE id=?", (message_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(f"message {message_id} not found")
|
||||
con.execute("UPDATE messages SET body=? WHERE id=?", (body, message_id))
|
||||
con.commit()
|
||||
updated = con.execute("SELECT * FROM messages WHERE id=?", (message_id,)).fetchone()
|
||||
return dict(updated)
|
||||
finally:
|
||||
con.close()
|
||||
|
|
|
|||
|
|
@ -159,12 +159,11 @@ def test_draft_without_llm_returns_402(fresh_db, monkeypatch):
|
|||
|
||||
# Ensure has_configured_llm returns False at both import locations
|
||||
monkeypatch.setattr("app.wizard.tiers.has_configured_llm", lambda *a, **kw: False)
|
||||
# Force free tier via the tiers module (not via header — header is no longer trusted)
|
||||
monkeypatch.setattr("app.wizard.tiers.effective_tier", lambda: "free")
|
||||
|
||||
client = TestClient(dev_api.app)
|
||||
resp = client.post(
|
||||
f"/api/contacts/{contact_id}/draft-reply",
|
||||
headers={"X-CF-Tier": "free"},
|
||||
)
|
||||
resp = client.post(f"/api/contacts/{contact_id}/draft-reply")
|
||||
assert resp.status_code == 402
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,16 @@ export const useMessagingStore = defineStore('messaging', () => {
|
|||
return data.message_id
|
||||
}
|
||||
|
||||
async function updateMessageBody(id: number, body: string) {
|
||||
const { data, error: fetchErr } = await useApiFetch<Message>(
|
||||
`/api/messages/${id}`,
|
||||
{ method: 'PUT', body: JSON.stringify({ body }), headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
if (fetchErr || !data) { error.value = 'Failed to save edits.'; return null }
|
||||
messages.value = messages.value.map(m => m.id === id ? { ...m, body: data.body } : m)
|
||||
return data
|
||||
}
|
||||
|
||||
async function approveDraft(messageId: number): Promise<string | null> {
|
||||
const { data, error: fetchErr } = await useApiFetch<{ body: string; approved_at: string }>(
|
||||
`/api/messages/${messageId}/approve`,
|
||||
|
|
@ -159,6 +169,6 @@ export const useMessagingStore = defineStore('messaging', () => {
|
|||
messages, templates, loading, saving, error, draftPending,
|
||||
fetchMessages, fetchTemplates, createMessage, deleteMessage,
|
||||
createTemplate, updateTemplate, deleteTemplate,
|
||||
requestDraft, approveDraft, clear,
|
||||
requestDraft, approveDraft, updateMessageBody, clear,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,21 +40,12 @@
|
|||
<button class="btn btn--ghost" @click="openLogModal('in_person')">Log note</button>
|
||||
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
|
||||
<button
|
||||
v-if="canDraftLlm"
|
||||
class="btn btn--primary"
|
||||
:disabled="store.loading"
|
||||
@click="requestDraft"
|
||||
>
|
||||
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
|
||||
</button>
|
||||
<a
|
||||
v-else-if="!canDraftLlm"
|
||||
href="#/settings/connections"
|
||||
class="btn-enable-llm"
|
||||
title="Configure an LLM backend to enable AI reply drafting"
|
||||
>
|
||||
Enable LLM drafts →
|
||||
</a>
|
||||
|
||||
<!-- Osprey (Phase 2 stub) — aria-disabled, never hidden -->
|
||||
<button
|
||||
|
|
@ -286,18 +277,8 @@ function updateDraftBody(id: number, value: string) {
|
|||
|
||||
// ── LLM draft + approval ──────────────────────────────────────────────────
|
||||
|
||||
const canDraftLlm = ref(false)
|
||||
const draftAnnouncement = ref('')
|
||||
|
||||
async function checkLlmAvailable() {
|
||||
await useApiFetch<{ available: boolean }>('/api/vision/health')
|
||||
// Re-use vision health route as a proxy for LLM config — replace with a dedicated
|
||||
// /api/llm/health endpoint if one is added in future.
|
||||
// For now: canDraftLlm is set based on whether user has any configured LLM
|
||||
// (the server will 402 if not — the button is just a hint, not a gate)
|
||||
canDraftLlm.value = true // Always show; server enforces the real gate
|
||||
}
|
||||
|
||||
async function requestDraft() {
|
||||
// Find the most recent inbound job_contact for this job
|
||||
const inbound = jobContacts.value.find(c => c.direction === 'inbound')
|
||||
|
|
@ -314,18 +295,18 @@ async function requestDraft() {
|
|||
}
|
||||
|
||||
async function approveDraft(messageId: number) {
|
||||
// Use the locally-edited body if the user changed it
|
||||
const editedBody = draftBodyEdits.value[messageId]
|
||||
// Persist edits to DB before approving so history shows final version
|
||||
if (editedBody !== undefined) {
|
||||
// Patch body via a NOOP approve — server sets approved_at and returns body
|
||||
// The edited body is in our local state; copy it to clipboard from local state
|
||||
const updated = await store.updateMessageBody(messageId, editedBody)
|
||||
if (!updated) return // error already set in store
|
||||
}
|
||||
const body = await store.approveDraft(messageId)
|
||||
if (body) {
|
||||
const finalBody = editedBody ?? body
|
||||
await navigator.clipboard.writeText(finalBody)
|
||||
draftAnnouncement.value = 'Approved and copied to clipboard.'
|
||||
setTimeout(() => { draftAnnouncement.value = '' }, 3000 )
|
||||
setTimeout(() => { draftAnnouncement.value = '' }, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -423,7 +404,7 @@ function formatTime(iso: string): string {
|
|||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadJobs(), store.fetchTemplates(), checkLlmAvailable()])
|
||||
await Promise.all([loadJobs(), store.fetchTemplates()])
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
|
@ -489,10 +470,6 @@ onUnmounted(() => {
|
|||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.btn-enable-llm {
|
||||
font-size: var(--text-sm); color: var(--app-primary);
|
||||
text-decoration: none; padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
.btn--osprey {
|
||||
opacity: 0.5; cursor: not-allowed;
|
||||
background: none; border: 1px dashed var(--color-border);
|
||||
|
|
|
|||
Loading…
Reference in a new issue