Short answer: make structured-output validation the admission rule for a property catalog summarizer, then split by meaning, count tokens, estimate the complete job, and publish only a validated record. A cheap summarization API is useful only after those controls exist; otherwise a low per-call price just makes bad catalog data arrive faster.
Property descriptions are a particularly unforgiving input. “2 bed, 1.5 bath, pet friendly” can sit beside a marketing paragraph, a pasted inspection note, or a half-finished sentence. The output still has to be a record that search, pricing, and listing pages can trust. The architecture should therefore optimize for recoverable correctness, not for the shortest request path.
The practical shape is a durable map-and-reduce job. A planner creates bounded source chunks, a worker produces typed candidates, and a validator either accepts each candidate or sends it to a defined repair path. The reducer sees accepted records, not whatever text happened to come back first.
Start with an output contract, not a prompt
For this catalog, the contract might contain bedrooms, bathrooms, allows_pets, parking_spaces, and amenities, with each field carrying a value, a source span, and a confidence classification. Unknown is a valid value. Guessing “2” because a description says “two generous rooms” is not enrichment; it is data corruption with a friendly tone.
The contract needs rules that ordinary JSON syntax cannot express. A bathroom count cannot be negative. allows_pets must distinguish “pets allowed” from “pet policy available on request.” A source span must point back to the input version used for extraction. If the model returns a syntactically valid object with no evidence for a value, validation should reject that field while preserving the rest of the candidate for review.
Keep it boring.
Here is a local validation boundary. It is intentionally independent of a model or hosted API, because the catalog's correctness policy should survive a provider change. Consider a listing that says “two bedrooms plus a den; pets considered with approval; parking may be available.” A careless extractor can emit bedrooms: 3, allows_pets: true, and parking_spaces: 1, all of which look reasonable in a database row and all of which overstate what the source says. The contract should instead keep bedrooms at 2, represent the den as an amenity or a separate explicitly defined field, leave the pet decision unresolved if the schema has no “conditional” state, and leave parking unknown. That decision may feel conservative to a product team trying to fill every column, but an unknown value can be reviewed and corrected while an invented fact can be indexed, displayed, and copied into a lease workflow before anyone notices. The validator is where that discipline becomes executable.
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class Decision:
accepted: bool
reasons: tuple[str, ...]
def validate_property_record(record: dict[str, Any]) -> Decision:
reasons: list[str] = []
required = {"bedrooms", "bathrooms", "allows_pets", "amenities"}
missing = sorted(required - record.keys())
if missing:
reasons.append(f"missing fields: {', '.join(missing)}")
for name in ("bedrooms", "bathrooms"):
value = record.get(name)
if value is not None and (not isinstance(value, int) or value < 0):
reasons.append(f"{name} must be a non-negative integer or null")
if not isinstance(record.get("allows_pets"), (bool, type(None))):
reasons.append("allows_pets must be boolean or null")
if not isinstance(record.get("amenities"), list):
reasons.append("amenities must be a list")
for field in record:
if field not in required and field != "parking_spaces":
reasons.append(f"unexpected field: {field}")
return Decision(accepted=not reasons, reasons=tuple(reasons))
This does not prove that a model understood a sentence. It proves something narrower and more valuable: downstream code receives the shape it was promised. Keep semantic checks, evidence checks, and human-review rules beside this boundary rather than burying them in prompt prose.
How should a Node.js SaaS split, count, and budget long descriptions?
The Node.js service can orchestrate the work, but character count is not a token count. A long amenity list with unusual punctuation and a paragraph in another writing system can consume a different number of tokens than a similarly sized marketing paragraph. Use the tokenizer or counting surface associated with the selected model, and reserve space for instructions and the requested structured response.
Split at paragraph or sentence boundaries first. If a candidate still exceeds its input allowance, split that candidate again; do not silently truncate it. The planner should record the source offsets, token count, prompt version, output budget, and model configuration in a manifest before a worker starts. A reducer needs the same treatment, since a collection of valid partial records can still exceed its own input allowance.
The cost estimate should cover the whole tree: map calls, intermediate reductions, the final reduction, and the output allowance selected by the product mode. A preview mode may cap output more tightly than an audit mode. The important decision is made before admission, when the service can still ask for a shorter mode or reject the job without producing a partial catalog record.
No guesswork.
from dataclasses import dataclass
@dataclass(frozen=True)
class Chunk:
chunk_id: str
input_tokens: int
def plan(chunks: list[Chunk], input_limit: int, estimated_cost: float,
document_ceiling: float) -> dict[str, object]:
oversized = [chunk.chunk_id for chunk in chunks
if chunk.input_tokens > input_limit]
if oversized:
return {"decision": "split_again", "chunk_ids": oversized}
if estimated_cost > document_ceiling:
return {
"decision": "request_brief_mode_or_reject",
"estimated_cost": estimated_cost,
}
return {
"decision": "admit",
"chunk_count": len(chunks),
"estimated_cost": estimated_cost,
}
The estimate is a control signal, not an invoice. Store actual usage after each completed call and compare it with the estimate by document type and mode. Large drift usually means the planner is missing reducer work, output limits are too loose, or the sample corpus differs from production. Your mileage may vary across languages and model configurations, so a threshold that passes a single English listing is not evidence of a safe global policy.
What failure modes make a plausible summary unsafe?
The dangerous failures are quiet ones. A worker can omit a chunk, merge two properties, turn an unresolved pet policy into false, or return a perfectly parseable object with invented values. A retry can then overwrite a better candidate, and a reducer can publish a fluent record while nobody notices that one source range was absent.
Treat every candidate as an evidence-bearing proposal. Keep the input revision, source offsets, chunk identifier, schema version, prompt version, validation result, and usage metadata. Do not log raw descriptions by default; catalog text may contain names, phone numbers, or access details. Logs should explain a decision without becoming another copy of the tenant's data.
The manifest is the durable unit of work. Completion means every expected child has an accepted result and the reducer has written its output. A status flag alone is insufficient. If the final object is written before the manifest transition, a reader can observe a result that the job later considers incomplete. Use an atomic metadata transition or compare-and-set operation appropriate to the store, and make result keys deterministic from the source revision and configuration.
I would test the validator with adversarial fixtures before comparing models: “one bedroom plus den,” “no pets,” “pets considered,” conflicting bathroom counts, repeated amenities, empty descriptions, and a listing that ends halfway through a sentence. The expected output should state what remains unknown. A pretty summary is not a passing test.
Compare architecture choices by the work they leave behind
There is no universal best summarization API for a SaaS feature. The useful comparison is which responsibilities remain in your service after the model call.
| Architecture | Useful when | Responsibility that remains yours |
|---|---|---|
| Direct hosted model call | The team wants the smallest request path | Chunk manifests, validation, routing, retention, and usage accounting |
| Self-hosted gateway | The team needs one internal control point across model backends | Gateway operations, upgrades, capacity, and model-specific behavior |
| Managed routing layer | The product needs provider choice behind one application boundary | Portability tests, policy enforcement, and the durable job state |
| Queue-backed worker pool | Documents vary enough that synchronous requests are risky | Idempotency, retry policy, ordering, and user-visible progress |
The table is a warning against measuring only request price. A gateway can simplify model routing while adding an operating surface. A direct call can reduce moving parts while leaving policy and observability in application code. A queue can protect the web process while making duplicate delivery and cancellation explicit design problems.
Keep a candidate provider only if it can meet the contract and expose enough usage information for your budget policy. Reject one that makes schema validation, source evidence, or deletion guarantees impossible to verify. The catch is that this recommendation is not suitable when the product requires a provider-specific feature that cannot be represented by your neutral contract; stick with the native integration then, and isolate it behind the same planner and validator.
Roll out with observable decisions
Start with one document type and two user-visible modes, such as preview and verified. Emit metrics for admission decisions, split depth, estimate-to-actual drift, validation rejection reasons, retry counts, reducer depth, and time spent waiting for human review. Do not use a single “AI success” counter; it hides the failures that damage the catalog.
Expose progress as state derived from the manifest. Server-Sent Events can stream state changes to a browser, but an open connection is not job ownership. A reconnecting client should read the current manifest and continue from a durable cursor; the worker should keep running when the browser disappears. MDN's SSE guidance is useful for the transport details, while the job state belongs in your application data layer.
For migration, shadow the new extractor against existing descriptions, compare field-level disagreements, and sample rejected records. Publish only records that pass the contract. Once the rejection reasons stabilize, raise concurrency gradually and retain a rollback path to the previous catalog values. The rollout is complete when correctness is measurable, not when the first batch returns a summary.
References
- https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
- https://github.com/BerriAI/litellm
Top comments (0)