Files
2026-07-19 18:57:52 +00:00

539 lines
21 KiB
Python

"""Property manager one-page document generator.
A deterministic full-stack A2A product that turns a short property-management
intake into a polished one-page client document, emits a markdown artifact, and
persists an execution receipt when the managed database is available.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import os
import re
import uuid
from datetime import datetime, timezone
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field, field_validator
import a2a_pack as a2a
from a2a_pack import (
A2AAgent,
AccountAccess,
AgentDatabase,
AgentDatabaseEnv,
AgentDatabaseMigrations,
AgentPlatformResources,
LLMProvisioning,
PlatformUserAuth,
Pricing,
Resources,
RunContext,
WorkspaceAccess,
WorkspaceMode,
)
from a2a_pack.workspace import FileUpload, UploadedFile
OUTPUT_FILENAME = "high-utility-one-page-startups-w-e3c7-3-output.md"
MAX_TEXT_CHARS = 5_000
MAX_UPLOAD_BYTES = 256_000
MAX_BROWSER_UPLOADS = 2
ALLOWED_MEDIA_TYPES = {
"text/plain",
"text/markdown",
"application/json",
"text/csv",
}
class HighUtilityOnePageStartupsWE3c73Config(BaseModel):
default_city: str = "Austin"
class BrowserDocument(BaseModel):
filename: str = Field(..., min_length=1, max_length=120)
media_type: str = Field(..., min_length=3, max_length=80)
data_base64: str = Field(..., min_length=1, max_length=((MAX_UPLOAD_BYTES * 4) // 3) + 16)
@field_validator("filename")
@classmethod
def clean_filename(cls, value: str) -> str:
cleaned = value.replace("\\", "/").split("/")[-1].strip()
if not cleaned or cleaned in {".", ".."}:
raise ValueError("filename is required")
if any(ch in cleaned for ch in "\x00\r\n"):
raise ValueError("filename contains invalid characters")
return cleaned[:120]
@field_validator("media_type")
@classmethod
def allowed_media_type(cls, value: str) -> str:
cleaned = value.strip().lower()
if cleaned not in ALLOWED_MEDIA_TYPES:
raise ValueError(
"unsupported media type; use text/plain, text/markdown, application/json, or text/csv"
)
return cleaned
class DocumentRequest(BaseModel):
client_name: str = Field(..., min_length=1, max_length=120)
property_name: str = Field(..., min_length=1, max_length=160)
property_type: Literal["multifamily", "single-family", "commercial", "mixed-use", "hoa"] = "multifamily"
city: str = Field(..., min_length=1, max_length=80)
goal: Literal["owner update", "leasing plan", "maintenance brief", "management proposal"] = "owner update"
intake: str = Field(..., min_length=20, max_length=MAX_TEXT_CHARS)
tone: Literal["professional", "warm", "executive", "urgent"] = "professional"
include_next_steps: bool = True
uploaded_documents: list[BrowserDocument] = Field(default_factory=list, max_length=MAX_BROWSER_UPLOADS)
class UploadedDocumentRequest(BaseModel):
client_name: str = Field(..., min_length=1, max_length=120)
property_name: str = Field(..., min_length=1, max_length=160)
city: str = Field(..., min_length=1, max_length=80)
goal: Literal["owner update", "leasing plan", "maintenance brief", "management proposal"] = "owner update"
intake: str = Field(..., min_length=20, max_length=MAX_TEXT_CHARS)
class DocumentDescriptor(BaseModel):
name: str
filename: str
media_type: str
size_bytes: int
artifact_uri: str
content_base64: str
class DocumentResult(BaseModel):
status: Literal["ok", "validation_error", "persistence_warning"]
document_preview: str
document: DocumentDescriptor | None
receipt_id: str
persisted_receipt: bool
warnings: list[str] = Field(default_factory=list)
highlights: list[str] = Field(default_factory=list)
class UploadValidationResult(BaseModel):
status: Literal["ok", "validation_error"]
extracted_text: str = ""
filename: str = ""
media_type: str = ""
size_bytes: int = 0
message: str = ""
class HighUtilityOnePageStartupsWE3c73(A2AAgent[HighUtilityOnePageStartupsWE3c73Config, PlatformUserAuth]):
name = "high-utility-one-page-startups-w-e3c7-3"
description = (
"One-page document generator for property managers that turns a short intake "
"into a polished client document with preview and markdown artifact download."
)
version = "0.1.0"
config_model = HighUtilityOnePageStartupsWE3c73Config
auth_model = PlatformUserAuth
# Required by the account trial/BYOK launch contract. The implementation is
# deterministic and does not read ctx.llm or provider secrets.
llm_provisioning = LLMProvisioning.PLATFORM
account_access = AccountAccess(required=True, platform_skill_calls=3, after_trial="byok")
pricing = Pricing(
price_per_call_usd=0.0,
caller_pays_llm=True,
notes="Includes 3 platform-funded skill calls per account, then requires BYOK per launch contract.",
)
resources = Resources(cpu="500m", memory="512Mi", max_runtime_seconds=120)
workspace_access = WorkspaceAccess.dynamic(
max_files=16,
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
require_reason=False,
max_total_size_bytes=2 * MAX_UPLOAD_BYTES,
)
tools_used = ("artifacts", "mcp", "postgres")
platform_resources = AgentPlatformResources(
databases=(
AgentDatabase(
name="high-utility-one-page-startups-w-e3c7-3-data",
scope="user",
access_mode="read_write",
env=AgentDatabaseEnv(url="DATABASE_URL"),
migrations=AgentDatabaseMigrations(path="db/migrations"),
),
)
)
@a2a.tool(
name="generate_document",
description="Generate a polished one-page property-management client document, preview, receipt, and markdown artifact.",
timeout_seconds=120,
idempotent=False,
cost_class="standard",
grant_mode="read_write_overlay",
grant_allow_patterns=("outputs/documents/**",),
grant_outputs_prefix="outputs/documents/",
grant_write_prefixes=("outputs/documents/",),
)
async def generate_document(
self,
ctx: RunContext[PlatformUserAuth],
request: DocumentRequest,
) -> DocumentResult:
tenant = _tenant_key(ctx)
upload_texts: list[str] = []
warnings: list[str] = []
for item in request.uploaded_documents:
parsed = _decode_browser_document(item)
if parsed.status != "ok":
return DocumentResult(
status="validation_error",
document_preview="",
document=None,
receipt_id=_receipt_id(tenant, request.model_dump(mode="json")),
persisted_receipt=False,
warnings=[parsed.message],
)
upload_texts.append(f"Attachment {parsed.filename}: {parsed.extracted_text}")
document_text, highlights = _build_document(request, upload_texts)
data = document_text.encode("utf-8")
ref = await ctx.write_artifact(OUTPUT_FILENAME, data, "text/markdown")
await ctx.emit_artifact(ref)
receipt_id = _receipt_id(tenant, {**request.model_dump(mode="json"), "artifact": ref.uri})
persisted = await _persist_receipt(
tenant_key=tenant,
receipt_id=receipt_id,
skill_name="generate_document",
inputs=request.model_dump(mode="json"),
result={
"filename": OUTPUT_FILENAME,
"artifact_uri": ref.uri,
"size_bytes": len(data),
"preview_hash": hashlib.sha256(data).hexdigest(),
},
)
if not persisted:
warnings.append("Receipt persistence is unavailable in this local/runtime context.")
return DocumentResult(
status="ok" if persisted else "persistence_warning",
document_preview=document_text,
document=DocumentDescriptor(
name="document",
filename=OUTPUT_FILENAME,
media_type="text/markdown",
size_bytes=len(data),
artifact_uri=ref.uri,
content_base64=base64.b64encode(data).decode("ascii"),
),
receipt_id=receipt_id,
persisted_receipt=persisted,
warnings=warnings,
highlights=highlights,
)
@a2a.tool(
name="validate_browser_upload",
description="Validate a bounded browser base64 upload and extract safe text for the document generator.",
timeout_seconds=30,
idempotent=True,
)
async def validate_browser_upload(
self,
ctx: RunContext[PlatformUserAuth],
document: BrowserDocument,
) -> UploadValidationResult:
_tenant_key(ctx) # proves platform auth and tenant boundary for MCP/API calls
return _decode_browser_document(document)
@a2a.tool(
name="generate_document_from_upload",
description="External client upload path: generate a document using a typed FileUpload plus form fields.",
timeout_seconds=120,
idempotent=False,
cost_class="standard",
grant_mode="read_write_overlay",
grant_allow_patterns=("uploads/**", "outputs/documents/**"),
grant_outputs_prefix="outputs/documents/",
grant_write_prefixes=("outputs/documents/",),
)
async def generate_document_from_upload(
self,
ctx: RunContext[PlatformUserAuth],
uploaded_file: Annotated[
UploadedFile,
FileUpload(
accept=tuple(sorted(ALLOWED_MEDIA_TYPES)),
max_bytes=MAX_UPLOAD_BYTES,
description="Optional intake notes or source text for the generated document.",
),
],
request: UploadedDocumentRequest,
) -> DocumentResult:
try:
raw = ctx.workspace.read_bytes(uploaded_file.path)
except Exception as exc: # noqa: BLE001
return DocumentResult(
status="validation_error",
document_preview="",
document=None,
receipt_id="",
persisted_receipt=False,
warnings=[f"Unable to read uploaded file: {type(exc).__name__}"],
)
browser_doc = BrowserDocument(
filename=uploaded_file.filename,
media_type=uploaded_file.media_type,
data_base64=base64.b64encode(raw[: MAX_UPLOAD_BYTES + 1]).decode("ascii"),
)
parsed = _decode_browser_document(browser_doc)
if parsed.status != "ok":
return DocumentResult(
status="validation_error",
document_preview="",
document=None,
receipt_id="",
persisted_receipt=False,
warnings=[parsed.message],
)
full_request = DocumentRequest(
client_name=request.client_name,
property_name=request.property_name,
city=request.city,
goal=request.goal,
intake=request.intake,
uploaded_documents=[browser_doc],
)
return await self.generate_document(ctx, full_request)
def _tenant_key(ctx: RunContext[PlatformUserAuth]) -> str:
auth = ctx.auth
stable_id = auth.user_id if auth.user_id is not None else auth.sub
if stable_id is None or str(stable_id).strip() == "":
raise PermissionError("stable platform identity required")
return f"user:{stable_id}"
def _decode_browser_document(document: BrowserDocument) -> UploadValidationResult:
try:
raw = base64.b64decode(document.data_base64, validate=True)
except (binascii.Error, ValueError):
return UploadValidationResult(
status="validation_error",
filename=document.filename,
media_type=document.media_type,
message="Upload must be valid base64.",
)
if len(raw) > MAX_UPLOAD_BYTES:
return UploadValidationResult(
status="validation_error",
filename=document.filename,
media_type=document.media_type,
size_bytes=len(raw),
message=f"Upload is too large; maximum is {MAX_UPLOAD_BYTES} bytes.",
)
text = raw.decode("utf-8", errors="replace")
text = _compact_whitespace(text)[:2_000]
if not text.strip():
return UploadValidationResult(
status="validation_error",
filename=document.filename,
media_type=document.media_type,
size_bytes=len(raw),
message="Upload did not contain readable UTF-8 text.",
)
return UploadValidationResult(
status="ok",
extracted_text=text,
filename=document.filename,
media_type=document.media_type,
size_bytes=len(raw),
message="Upload accepted.",
)
def _build_document(request: DocumentRequest, upload_texts: list[str]) -> tuple[str, list[str]]:
intake = _compact_whitespace(request.intake)
sentences = _split_sentences(intake)
bullets = _select_bullets(sentences, upload_texts)
goal_label = request.goal.title()
tone_line = {
"professional": "clear, practical, and client-ready",
"warm": "approachable while staying specific and action-oriented",
"executive": "concise, decision-focused, and outcome-led",
"urgent": "direct, timely, and focused on immediate resolution",
}[request.tone]
next_steps = _next_steps(request.goal, request.property_type) if request.include_next_steps else []
highlights = bullets[:3]
lines = [
f"# {goal_label}: {request.property_name}",
"",
f"**Prepared for:** {request.client_name} ",
f"**Asset:** {request.property_name} ({request.property_type.replace('-', ' ')}) ",
f"**Market:** {request.city} ",
f"**Date:** {datetime.now(timezone.utc).date().isoformat()}",
"",
"## Executive Summary",
f"This one-page brief turns the current intake into a {tone_line} client document for {request.client_name}. "
f"The immediate objective is to support a polished {request.goal} for {request.property_name} while keeping the action plan simple enough to start this week.",
"",
"## Situation Snapshot",
]
lines.extend(f"- {bullet}" for bullet in bullets)
lines.extend([
"",
"## Recommended Client Message",
_client_message(request, bullets),
"",
"## Operating Priorities",
])
priorities = _priorities(request.goal, request.property_type)
lines.extend(f"- **{title}:** {detail}" for title, detail in priorities)
if next_steps:
lines.extend(["", "## Next Steps"])
lines.extend(f"{idx}. {step}" for idx, step in enumerate(next_steps, start=1))
if upload_texts:
lines.extend(["", "## Source Notes Used"])
for item in upload_texts[:MAX_BROWSER_UPLOADS]:
lines.append(f"- {_truncate(item, 220)}")
lines.extend([
"",
"---",
"Generated by high-utility-one-page-startups-w-e3c7-3 for fast property-management client communication.",
])
return "\n".join(lines) + "\n", highlights
def _client_message(request: DocumentRequest, bullets: list[str]) -> str:
opener = {
"owner update": "Here is the concise owner-facing update I recommend sending now:",
"leasing plan": "Here is the leasing-plan narrative I recommend sharing with stakeholders:",
"maintenance brief": "Here is the maintenance brief language I recommend using:",
"management proposal": "Here is the proposal-ready positioning I recommend leading with:",
}[request.goal]
return (
f"{opener} {request.property_name} is being managed with focus on "
f"{bullets[0].rstrip('.').lower()}. The plan is to keep communication tight, "
f"make the next operational move visible, and give {request.client_name} a clear basis for approval or follow-up."
)
def _priorities(goal: str, property_type: str) -> list[tuple[str, str]]:
common = [
("Owner confidence", "Lead with what changed, what is controlled, and what decision is needed."),
("Resident or tenant experience", "Convert intake details into visible service improvements and clear timelines."),
]
by_goal = {
"owner update": ("Reporting cadence", "Send a short weekly status note until the current issue or initiative is closed."),
"leasing plan": ("Demand capture", "Refresh pricing, response speed, and showing follow-up before adding new spend."),
"maintenance brief": ("Resolution path", "Separate urgent safety items from cosmetic work and assign owners to each."),
"management proposal": ("Value proof", "Tie management actions to NOI protection, retention, and reduced owner friction."),
}
type_priority = ("Asset fit", f"Tune the plan for a {property_type.replace('-', ' ')} property rather than a generic template.")
return [common[0], by_goal[goal], type_priority, common[1]]
def _next_steps(goal: str, property_type: str) -> list[str]:
if goal == "leasing plan":
return [
"Confirm current vacancy, pricing, concessions, and lead-response time within 24 hours.",
"Publish one refreshed leasing message and track qualified inquiries for seven days.",
"Send the owner a short results note with the next pricing or marketing adjustment.",
]
if goal == "maintenance brief":
return [
"Classify open items by safety, revenue risk, and resident impact.",
"Assign each item an owner, budget range, and target completion date.",
"Share a completion photo or vendor note for every closed item.",
]
if goal == "management proposal":
return [
f"Lead with the two highest-friction problems typical for this {property_type.replace('-', ' ')} asset.",
"Attach a 30-day transition checklist with owner approvals clearly marked.",
"Close with the first measurable operating win the manager will deliver.",
]
return [
"Send the one-page update to the client and ask for one approval or correction.",
"Turn the operating priorities into assigned tasks with dates.",
"Follow up with a short progress note after the first completed action.",
]
def _split_sentences(text: str) -> list[str]:
parts = re.split(r"(?<=[.!?])\s+|\n+", text)
return [_truncate(part.strip(" -•\t"), 180) for part in parts if part.strip()]
def _select_bullets(sentences: list[str], upload_texts: list[str]) -> list[str]:
bullets = []
for sentence in sentences[:5]:
clean = sentence.rstrip(".")
if clean:
bullets.append(clean[0].upper() + clean[1:] + ".")
for text in upload_texts:
if len(bullets) >= 6:
break
bullets.append(_truncate(_compact_whitespace(text), 180).rstrip(".") + ".")
while len(bullets) < 4:
fallback = [
"Client communication should be concise, specific, and tied to the next operating decision.",
"The manager should turn the intake into a visible action plan rather than a long narrative.",
"Follow-up should include an owner, due date, and evidence of completion.",
"The document should be usable immediately in an email or meeting recap.",
][len(bullets)]
bullets.append(fallback)
return bullets[:6]
def _compact_whitespace(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
def _truncate(text: str, limit: int) -> str:
return text if len(text) <= limit else text[: limit - 1].rstrip() + ""
def _receipt_id(tenant_key: str, payload: dict[str, Any]) -> str:
material = json.dumps(payload, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(f"{tenant_key}:{material}:{uuid.uuid4().hex}".encode("utf-8")).hexdigest()
return f"rct_{digest[:24]}"
async def _persist_receipt(
*,
tenant_key: str,
receipt_id: str,
skill_name: str,
inputs: dict[str, Any],
result: dict[str, Any],
) -> bool:
database_url = os.environ.get("DATABASE_URL")
if not database_url:
return False
try:
import psycopg
from psycopg.types.json import Jsonb
options = "-c statement_timeout=5000 -c lock_timeout=3000 -c idle_in_transaction_session_timeout=5000"
with psycopg.connect(database_url, options=options) as conn:
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO execution_receipts
(tenant_key, receipt_id, skill_name, input_json, result_json)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (tenant_key, receipt_id) DO UPDATE SET
result_json = EXCLUDED.result_json,
updated_at = NOW()
""",
(tenant_key, receipt_id, skill_name, Jsonb(inputs), Jsonb(result)),
)
return True
except Exception:
return False