Javis OS
Javis OS는 기업가를 돕는 에이전시 AI로, 업무를 지원하고 함께 성장합니다
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기解决企业在“找标、看标、判断能不能投”阶段的高成本问题
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
AI Tender Intelligence Platform — 解决企业在"找标、看标、判断能不能投"阶段的高成本问题。 Discover. Understand. Qualify. 发现招标机会 → 快速理解招标要求 → 判断企业是否具备投标资格。README 전문 보기 ↗
AI Tender Intelligence Platform — 解决企业在"找标、看标、判断能不能投"阶段的高成本问题。
Discover. Understand. Qualify.
发现招标机会 → 快速理解招标要求 → 判断企业是否具备投标资格。
Positioning: for Chinese enterprises expanding into Mexico — the
differentiator against Spanish-native local competitors (LicitIA, Licitacom,
etc.) is the language/translation layer, not raw data aggregation, which is
already a fairly crowded market. Per that same reasoning, the frontend is
Chinese-only by design (lib/i18n.tsx) — the en/es tender-intelligence
market already has enough similar sites; the underlying LocalizedText
data model still carries es/en/zh (Spanish stays the source-of-truth
field for real government data), it's just not rendered in the UI or
switchable anymore.
First market: Mexico public procurement (Compras MX, DOF).
npm run dev
Prerequisite for document ingestion/extraction only (npm run ingest:documents, npm run extract:document — not needed for npm run dev or the rest of the app): both call into lib/ingestion/document-intake.ts, which shells out to Poppler's pdftotext binary for PDF text extraction. Install it and confirm it's on PATH (pdftotext -v) before running either:
winget install --id=oschwartz10612.Poppler -e (or choco install poppler), then restart your terminalbrew install popplerapt install poppler-utils (Debian/Ubuntu) or your distro's equivalentOpen http://localhost:3000 with your browser to see the result.
Vercel deploys only main and non-claude/ branches. vercel.json sets git.deploymentEnabled to false for claude/**: each change used to produce three deployments (the working branch's push, that branch's fast-forward to the merge commit, and main's production build), and the working-branch previews were never opened — changes are checked locally before merging. Dependabot branches still get previews, which are the build check on those PRs.
This project was bootstrapped with create-next-app (TypeScript, App Router, Tailwind CSS, Turbopack).
app/ Next.js App Router routes (/, tenders, pricing, login, register, account, saved)
components/ UI components (tenders/, layout/)
data/ Mock / seed data
lib/ Shared utilities, Supabase clients, and the data-access layer
supabase/ SQL migrations
scripts/ One-off scripts (e.g. db:seed)
types/ Core domain types (Tender schema, etc.)
See ACCESS_CONTROL.md for the guest/member/subscriber permission matrix and the switch from the free launch stage to paid access.
The app works out of the box with bundled mock data (data/tenders.ts) — no
database required. To connect a real Supabase project:
supabase/migrations/ in
filename order (0001_init.sql creates all tables — tenders,
tender_requirements, tender_key_dates, tender_risks,
tender_documents, buyers, industries, profiles, subscriptions —
with indexes and Row Level Security policies, public read on tender data
and private read on profiles/subscriptions; each later-numbered file is a
small, additive schema change — new nullable columns, a renamed pricing
tier, etc. — never a rewrite of an earlier one)..env.example to .env.local and fill in the three values:
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
npm run db:seed
npm run dev — the app automatically prefers Supabase when these env
vars are set, and falls back to mock data otherwise (see
lib/tenders.ts).Register/log in/log out use Supabase Auth (email + password, plus a magic-link
option), via @supabase/ssr for session handling — proxy.ts (Next.js's
renamed middleware.ts convention) refreshes the session cookie on every
request. Auth state is read client-side (lib/auth.ts) so
pages stay statically prerendered; reading the session in a Server Component
would force the whole app into dynamic rendering just to know whether one
visitor is logged in.
By default, a new Supabase project requires email confirmation before a user can log in — after registering, check the inbox for that address. You can disable this in Authentication → Providers → Email → Confirm email in the Supabase dashboard if you'd rather test without it.
Saved tenders/searches (see below) are still localStorage-only, not tied
to the account yet — that migration to per-user Supabase storage is future
work now that accounts exist.
Note on schema shape: qualification, experience, and required-document
requirements share one tender_requirements table distinguished by a kind
column, since they're structurally identical. Localized text (title,
summary, description, etc.) is stored as jsonb — {"es","en","zh"} —
directly on each row rather than in a separate translations table, keeping
one row the single source of truth per the "original language wins" design
principle.
Manual editing (2026-09-05): /admin/tenders/[slug] — beyond the tender's
own flat fields, an admin can now add/edit/delete individual tender_key_dates,
tender_requirements (all three kinds), and tender_risks rows directly from
the edit page, for a tender with no source document to run the Layer 2
extraction pipeline against, or to correct/supplement its output by hand.
Each row persists immediately via its own /api/admin/tenders/[slug]/{key-dates,requirements,risks}[/[id]]
endpoint (not bundled into the surrounding form's single save), and only ever
writes the zh locale from that form — the product is Chinese-only
(lib/i18n.tsx hardcodes LOCALE = "zh"), so es/en are left as empty
strings rather than duplicated from the zh text. A new nullable
tenders.awarded_value column (migration 0019, distinct from
estimated_value — the pre-tender budget estimate, which a real award can
differ from) is editable the same way as the existing awarded_to/award_date
fields; the public tender detail page only ever shows the "中标结果" (award
result) block at all once awardedTo or awardedValue is actually set —
there's no empty/placeholder state for a tender that hasn't been awarded yet.
lib/ingestion/ — see lib/ingestion/README.md for the full picture:
confirmed portal structure, which mapper to trust most (the one built and
tested against real downloaded data, not documentation alone), and what's
deliberately not built yet (extracting qualifications/risks/documents needs
an LLM, Phase 6). Several mappers, each verifiable offline:
npm run ingest:comprasmx-contracts -- --fixture # real Compras MX contracts CSV → Tender (awarded/historical, built from real data)
npm run ingest:comprasmx-open -- --fixture # real "Difusión de procedimientos" export → Tender (still-open procedures, built from real data)
npm run ingest:compranet5 -- --fixture # older CompraNet 5.0 (2010-2022) bulk-export row → Tender
npm run ingest:compras-mx -- --fixture # OCDS release → Tender (format confirmed real, no sample record yet)
npm run ingest:dof -- --fixture # DOF daily-edition notice → Tender (CFE/PEMEX supplement, not a Compras MX replacement)
npm run ingest:dof-search -- --fixture # DOF advanced-search notice → Tender (same CFE/PEMEX supplement role)
npm run ingest:pemex -- --fixture # PEMEX's own SharePoint "Concursos Abiertos" list → Tender (no anti-bot gate, built from a real 2,067-item export)
ingest:pemex-attachments records real document names/URLs against
already-ingested PEMEX tenders (metadata only, no download) — see
lib/ingestion/README.md for the browser Console snippet that produces
its input file.
Each also accepts a real downloaded file in place of --fixture, e.g.
npm run ingest:comprasmx-contracts -- path/to/file.csv (dry run by
default; add --write to upsert into Supabase).
Tender documents (Convocatoria, Anexo Técnico, actas) are filed with a separate step, since Compras MX serves them from behind an anti-automation gate and this platform doesn't fetch through it:
npm run ingest:documents -- path/to/downloaded-pdfs # dry run; --write records them
Drop whatever was downloaded into a folder and it works out which tender each file belongs to (from the procedure number in the document text), what kind of document it is, and hashes it so the same file is never analysed twice — no renaming, sorting or matching by hand.
Where a source publishes real per-document URLs, the download stops being
manual. Peru's SEACE/OECE OCDS records carry them inline
(compiledRelease.tender.documents[].url), so ingest:peru-live records
them as it writes each tender, and /admin/documents-needed grows a
批量下载标书 button: select rows, click once, get one ZIP plus a
下载报告.txt naming every file that failed.
Every entry in that ZIP is named <slug>__<document name>, which is the
SLUG_OVERRIDE_PATTERN branch of match-documents-to-tenders.ts — the
first and only exact step in the matcher, no text extraction and no
ambiguity. Without the prefix these files fall through to "does any known
tender_number appear in the name or the extracted text", and the name alone
says nothing: every one of them is called Bases Administrativas.pdf. The
ZIP is flat for the same reason findDocuments() does not recurse — an
unzipped folder pointed at /admin/local-batch has to actually contain the
files.
Those links live in their own table, tender_document_links, not in
tender_documents. A row in tender_documents means "this platform holds
this file", and /admin/documents-needed treats one as "this tender is
handled" — writing discovered links there would have silently emptied the
worklist for every Peru tender the moment the links were captured, with
nothing actually downloaded.
Rows ingested before that capture existed have no links, so they get a backfill — re-reads the same OCDS segments and fills in only the links, touching no tender. It is a button on the 秘鲁 tab (local dev only, since it calls SEACE) and a command, both on the same implementation:
npm run backfill:peru-documents -- --months 6 --write # links for Peru rows ingested before this existed
Coverage is per-source and the UI says so rather than failing quietly:
| Source | Per-document links | Why |
|---|---|---|
| Peru — SEACE/OECE | ✅ inline in the OCDS record | Filtered to biddingDocuments + clarifications; award/evaluation documents are an outcome, not a bid input |
prod1.seace.gob.pe is slow and a single Bases Administrativas routinely
runs past 10MB, which took two real runs to get right. At a 20s per-file
deadline, 1 of 3 files arrived; at 60s, 3 of 4 — 23.1MB in 105s, about
110 KB/s per stream. Every reported "timeout" was a download working
normally.
The lesson is that no fixed per-file deadline can separate "slow" from
"broken" when a healthy transfer legitimately needs two minutes, and an
AbortSignal makes it worse by killing the body stream, not just the
connect. Silence is the signal that actually distinguishes them, so
lib/ingestion/download-file.ts resets its clock on every chunk: a slow file
takes as long as it takes, a dead one is dropped in 30s. Concurrency also
dropped from 4 to 2 — parallelism creates no bandwidth on a slow origin, it
splits one pipe N ways so nothing finishes early.
The batch carries a wall-clock budget (48s on Vercel, 270s locally), and
"ran out of batch time" and "not attempted" are reported as distinct from
"stalled" because each implies a different fix. npm run test:download
covers all of it against a local server that really trickles and really
stalls — including the case both wrong versions failed: a transfer slower
than any per-file deadline, which must still complete.
| Colombia — SECOP II | ⚠️ automatable, not wired up | colombia-documents-connector.ts downloads fine, but its proceso id matched 0 of 499 stored tenders on the first real run — wiring it before that is understood would return empty ZIPs |
| Mexico — Compras MX | ❌ never | Same anti-automation gate as its search API |
| Peru — ProInversión OxI | ❌ | The export carries a project detail link, no document URLs |
Confirmed 2026-09-11: SEACE/OECE's proxy answers
{"code":"403","message":"Forbidden",...} to requests from Vercel's
datacenter range (iad1), while the identical code pulls 2428 records for
the same segment from an ordinary connection. It is the operator's access
policy, and the data is reachable the way they allow, so it is not worked
around — npm run ingest:peru-live on the admin's own machine is the
supported path, and /admin/import-tenders/peru says so and hands over the
command pre-filled when the button fails. Running the same page under npm run dev locally works normally.
Two consequences worth stating plainly: an import cron for SEACE on Vercel is
impossible, not merely unbuilt — OxI (investinperu.pe, a different host, not
behind this proxy) is the only Peru source that could ever run on a schedule
there; and the document-link backfill is local-only for the same reason — it
appears as a panel on the 秘鲁 tab under npm run dev and 404s from the
deployed route, rather than shipping a button that spends two minutes to
produce a 403.
保留原因分析)npm run explain:kept groups the kept set by the first positive signal that
fired, so one loose pattern shows up as a large bucket with real titles under
it. The same diagnostic runs as a button in
/admin/import-tenders/maintenance, reading the live tenders table instead
of a reclassify:tenders CSV — so it is current, needs no terminal, and its
country filter covers Mexico and Colombia exactly as it covers Peru.
Read-only either way.
That 通用维护 tab is the one non-country tab in /admin/import-tenders, and
holds the three actions that run across the whole table rather than one
country's import: translate, reclassify, and this. It sits first in the tab
order, separated by a rule; /admin/import-tenders still lands on 墨西哥,
since maintenance is the tab you open on purpose, not the daily one.
Product direction has expanded to Latin America (Mexico, Brazil,
Colombia, Chile, Peru), positioned for Chinese enterprises bidding
overseas. Portuguese (for Brazil) is part of that long-term direction but
explicitly deferred for now. See the "Multi-country expansion" section in
lib/ingestion/README.md for what the country expansion means for
ingestion specifically. Mexico and Colombia have real connectors; Brazil,
Chile and Peru do not yet.
Colombia comes from SECOP II's public Socrata API (colombia-secop-live.ts)
rather than a downloaded file, and is filtered twice on the way in. The table
is ~9M rows, so the fetch is narrowed server-side to a publication-date
window plus a coarse modalidad_de_contratacion like '%icitaci%', and the
mapper then applies the exact gate: only Licitación pública and
Licitación pública Obra Pública are ingested at all
(isIngestedColombiaModalidad). Everything else — Contratación Directa,
régimen especial, selección abreviada, supplier information requests — never
enters the system.
That gate replaced an earlier rule that hid any Colombia tender with no submission deadline. The rule was the indirect version of the same intent and was wrong in both directions: it hid genuine open tenders whose deadline datos.gov.co had not synced yet (18 of 37 rows on one import, 14 of them flagship), while still admitting directa rows whenever they happened to carry a date.
The $order carries id_del_proceso as a tiebreaker, which is not
cosmetic: fecha_de_publicacion_del is shared by hundreds of rows and
Socrata gives no stable order within a tie, so $offset paging over it alone
could return one row twice and another never.
/admin/local-batch)A tender document can run to 900 pages / 100MB. Uploading those through a browser form is slow enough to be the bottleneck, so the dev server reads them off the operator's own disk instead: drop the downloaded files in one folder, paste the folder path, and every file is matched to its tender (by the procedure number inside the document), grouped so one tender's files are analysed together, and written back as a one-line summary plus qualifications / experience / required documents / risks. Identical files are skipped by content hash, so a re-run is not re-billed.
Page caps keep the token cost bounded: 20 pages for a standard tender, 30 for
significant, 40 for flagship (maxPagesForTier), truncated with
pdfseparate/pdfunite before the file is ever read into memory.
The route is local-only — it returns 404 when NODE_ENV === "production",
and the nav entry is compiled out of the deployed bundle. On a deployed
instance the path would address Vercel's filesystem, which would make the
endpoint an arbitrary-path file reader behind nothing but an admin cookie.
Setting it up on another computer. The tool runs wherever npm run dev
runs; nothing is installed globally.
npm install.PATH — pdfinfo, pdfseparate and pdfunite do the page
counting and truncation, pdftotext the text fallback. Install per the
Getting Started section above and confirm with pdfinfo -v..env.local. This tool needs six values and none of the Stripe/Resend
ones: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,
SUPABASE_SERVICE_ROLE_KEY, ADMIN_EMAILS (your own address),
DASHSCOPE_API_KEY (Qwen — the default extraction tiers), and
ANTHROPIC_API_KEY (scanned PDFs with no text layer route to Claude).
SUPABASE_SERVICE_ROLE_KEY bypasses RLS: move it through a password
manager, not chat or email, and never commit it.npm run dev, sign in with the ADMIN_EMAILS address, open
/admin/local-batch.The Supabase project is shared, so the second machine sees the same tenders
and writes to the same rows — only the folder path is local, and it must be
that machine's own absolute path (D:\tenders\2026-09,
/Users/you/tenders/2026-09).
If sign-in bounces you to the production site, the dev server is on a port
that is not in Supabase's redirect allow list. Supabase falls back to the
project's Site URL when redirect_to does not match, rather than erroring.
Add http://localhost:3000/** and whatever port next dev actually chose
(it takes 3001 when 3000 is busy) under Authentication → URL Configuration →
Redirect URLs.
An account whose email is in ADMIN_EMAILS resolves to a full entitlement in
getViewerEntitlement() — every paid gate on the public site opens, because
they all read that one function. Keyed on the same list that gates /admin,
so one env var decides who is staff and there is no second mechanism (a
comped subscription row, a flag column) to drift from it.
It grants access, not a fabricated subscription: no period, no billing link. The account page correctly shows nothing to renew.
Consequence: an admin cannot see the paywall as themselves. Checking what
a free or trial visitor sees needs an account that is not in ADMIN_EMAILS.
ACCESS_CONTROL.md has the rest of the entitlement rules.
Everything a tender goes through between a source row and the public list.
A tender has to survive all four to reach the feed. They run in this order, and knowing which one dropped something is usually the whole debugging job:
| # | Where | What it rejects |
|---|---|---|
| 1 | Connector ($where, server-side) | Outside the publication-date window; for Colombia, anything that is not a licitación |
| 2 | Mapper | Missing title/buyer/number/publication date; for Colombia, the exact modalidad gate |
| 3 | lib/relevance.ts | Below the value floor, or matching an exclude keyword, or carrying no target industry |
| 4 | upsert-tenders.ts | tier === "excluded" — never written to Supabase at all |
Gate 4 is why an excluded tender is absent rather than hidden. The public
list has its own excluded filter as a backstop, but only rows admitted
under older rules can ever hit it.
Gate 3 runs at import time and stores its verdict, so a row keeps the
tier the rules gave it on the day it was ingested. After changing a rule, run
npm run reclassify:tenders (or the 重新分类 panel) to recompute every stored
row's tier and industry tags; rows that are now excluded are deleted. It does
not re-apply gates 1 or 2 — a modalidad the mapper would now reject has to
be removed with SQL.
Every rule change is pinned by a fixture in lib/relevance-fixtures.ts —
every real title the user has confirmed a tier for, re-verified on every
change by npm run test:relevance. Add the new case there first: a
failure then tells you exactly which existing decision your rule would
break. That is not theoretical — a leading-"estudios" exclude rule was caught
contradicting a decision made four days earlier, and a narrower rule shipped
instead.
Ten categories (lib/industry.ts), multi-tag, keyword-matched. Education,
tax and mining are deliberately absent: the first two are emptied by this
platform's own exclude rules (school buildings, childcare, medical services,
consulting, tax-culture programs), and mining is granted by concession here
rather than tendered. Mining merged into energy_mining (能矿).
energy_mining and power stay separate — extraction and fuels (PEMEX) vs
the electricity grid (CFE), different buyers and different bidder pools.
The public industry filter lists only categories that currently have tenders behind them, computed from the unfiltered list. A dead filter option reads as a broken site rather than an empty category, and this needs no hide list to maintain: a category reappears by itself once a source supplies it.
lib/tender-status.ts computes what a reader sees from the stored status
plus the calendar: awarded/cancelled always win, a passed deadline closes
a tender, 澄清中 applies only on the day of the clarification meeting
itself, and everything else reads 招标中. 计划中 is not used.
Derived because the clarification rule is time-dependent — a stored status is
only correct on the day it was written, which is exactly how a one-day junta
de aclaraciones ended up displayed for weeks. Days are compared in
America/Mexico_City: the meeting happens where the meeting happens.
An admin edit to a tender is protected from the next import of that same
tender. tenders.manual_field_overrides (migration 0032) records the columns
a save actually changed — diffed against the stored row, not taken from
the request body, since the form posts every field every time and treating
"present" as "edited" would freeze the whole row on the first typo fix.
publication_date and its estimated flag lock as a pair.
tender_key_dates.manually_added (migration 0033) does the same for dates
typed into the 其他关键日期 editor: the importer refreshes key dates by
delete-then-insert, so without the flag a hand-entered 现场踏勘 vanished on
every import.
upsertTendersBatched() drops any tender whose submission_deadline has
already passed, from every source and every path — cron, CLI and admin button
— because that function is the one line all of them pass through.
It is not the recency window's job and could not have been.
filterRecentTenders() judges by publication date, and a source with no
publication-date column falls back to the ingestion timestamp
(publicationDateIsEstimated): always today, so those rows pass every window
however narrow. That is how Compras MX rows with 2023 and 2024 deadlines
reached the admin list under a one-month window. Separately, a Colombia row
published well inside the window can still close before the nightly run
reaches it.
Two exemptions: an awarded tender is kept (its deadline has passed by
definition and the result is the point), and a tender due today is still
imported — compared through platformDay(), the same rule
deriveTenderStatus() uses, so the importer and the site never disagree about
the same tender on the same day.
Every recency default is one month: the scheduled runs, the library entry
points and the CLI scripts. A wider window is a deliberate --months.
npm run purge:closed-tenders clears rows already stored (dry run by default,
CSV, counts by source).
Separately, an estimated publication date never overwrites a stored one. Sources with no real publication-date field fall back to the ingestion timestamp, so re-importing used to move a tender's 发布日期 forward every single run. A real date still overwrites anything — that is how an estimate gets corrected.
Protected columns are written back with their current stored value rather
than omitted from the upsert: ON CONFLICT DO UPDATE validates the proposed
INSERT tuple before detecting the conflict, so omitting a NOT NULL column
fails the whole statement even though only the UPDATE branch could run.
npm run translate:tenders -- --limit 20 # dry run, no API calls
npm run translate:tenders -- --limit 20 --write # translate 20 and save
npm run translate:tenders -- --write # everything still untranslated
es→zh on Qwen3.6-Plus via DashScope (DASHSCOPE_API_KEY), batched. Title and
summary are decided separately: a field is translated only if it is still
the untranslated mirror every mapper writes (zh === es, byte for byte) and
is not in manual_field_overrides. A human's Chinese is never overwritten,
and a hand-translated title no longer blocks its own summary from being
translated.
The admin 新项目清单 page's 翻译所有标题 button runs the same function.
npm run titles:public # dry run, no API calls
npm run titles:public -- --sample 20 # generate 20 for real and print them, write nothing
npm run titles:public -- --limit 200 --write # generate up to 200 and save
npm run titles:public -- --write # every row still missing one of the three
A second pass over the Chinese title and summary, filling three columns in one model call:
| Column | Audience | What it is |
|---|---|---|
title_zh_short (0054) | members | the condensed title shown in list rows and as the detail-page heading — place, asset, works type, with the procurement shell removed |
title_zh_public (0053) | guests, crawlers | the de-identified category title |
summary_zh_public (0054) | guests, crawlers | the de-identified summary |
One call rather than three passes: all three are rewrites of the same two
strings for different audiences, and separate passes can disagree with
themselves — a public title saying 隧道 above a public summary saying 公路
describes two different projects on one page.
Candidates come back newest-imported first (created_at), not
newest-published, so --limit 100 means "the hundred rows that arrived most
recently". Ordering by publication_date would answer a different question: a
tender published in August but imported yesterday sorts near the bottom by
that key, and a limited run would miss exactly the row that needs this most.
The admin 生成公开文案 panel (import-tenders → 维护) runs the same function through a web form. It is a separate panel from 翻译所有标题 and runs after it: the two passes select different rows — that one takes tenders with no Chinese translation yet, this one takes translated tenders with no generated display text — so one button doing both would make each row count mean something other than what it says. Leaving the write box unchecked runs a real sample (at most 20 rows, one model call) and prints all three strings with their verdicts, writing nothing.
title.zh and summary.zh are never written by this pass. They stay exactly
as translated — they are what findDroppedIdentifiers() guards, what the
admin screens edit, and the input this pass regenerates from — and clearing
the three columns returns the site to its previous behaviour. Since 0054 the
full Chinese title is no longer rendered anywhere on the front end: members
read the short title plus the original-language line underneath it, which is
the text that actually matches the official documents.
It exists because the translation above is right to do something that is
unsafe to publish. Both prompts keep the source proper noun in full-width
parentheses after a transliterated place, facility or project name —
马塔德罗(Matadero)泵站, 埃洛伊门德斯(Elói Mendes)市 — because "a
transliteration on its own appears in neither [the map nor the bid
documents]". That is exactly right for a bidder, and it makes title.zh the
best search key back to the official notice anywhere on this platform: paste
the parenthesised name into a search engine and you are on the source portal,
with no reason left to subscribe.
So the public title keeps what these pages actually rank for — the country,
the industry, the works type, the asset — and drops what identifies the row:
municipalities, districts, rivers, facilities, agency names, procurement
codes, Brazilian UF state codes and every Latin-script proper noun. Nobody
searches 瓜纳华托州莱昂市第三环路 by name unless they already know the
project; everybody searches 墨西哥 变电站 招标.
One pass covers Spanish and Portuguese both. The two translation prompts are separate and must be, but they converge here — by the time this runs, a Peruvian row and a Brazilian one are both Chinese carrying the same parenthesis anchor, and rows are never selected by language.
Read --sample before any --write. This string becomes the <title>, the
meta description and the JSON-LD name of every public page, so it is both the
de-identification and what those pages rank on — a prompt that over-strips
(墨西哥 工程项目 on every row) is as bad as one that under-strips: safe, and
ranking for nothing. Every candidate goes through publicTitleProblems()
(lib/public-title.ts) and is refused rather than written if it still
carries a Latin parenthetical, a procurement code, a chainage, a state code
or a masking artefact — a refused row simply keeps falling back to title.zh,
which is the behaviour that was already there.
0053 stopped publishing title.zh and left summary.zh alone, which undid
most of it: the summary is a faithful translation of the source objeto, and
every portal we ingest restates the project name, the municipality and often
the street there. It reached four surfaces at once — the card, the detail
page, the JSON-LD description, and the meta description, which is the
search-result snippet a reader sees without opening the page. Fixed in 0054
by generating summary_zh_public in the same pass.
The two public columns are read through different fallbacks, on purpose:
publicTitleOf() falls open to title.zh. It has to — the title is the
<h1>, the <title> and the JSON-LD name, so a fallback that returns
nothing blanks the page.publicSummaryOf() fails closed. A row with no generated summary shows
the generic placeholder; it never falls back to summary.zh. Every surface
that renders the summary already makes it conditional, so failing closed
costs a sentence rather than a page.scripts/test-public-tender.ts asserts both directions by value, because
"fall back to summary.zh so the page isn't empty" is the single most likely
way this gets quietly undone and it would look reasonable in review.
All three columns are deliberately absent from the importer's row builder, so
a re-import cannot reset them; scripts/test-public-title.ts enforces that.
Key dates come from two places, and reading them out of the bid document is no longer one of them (removed 2026-09-16).
tender_key_dates by the importer —
publication, PEMEX's validity_end, whatever a platform publishes as a
field.manually_added and nothing automated may touch them.The Layer 2 document extraction used to read the cronograma as a third source.
It was built for Peru, whose bases PDF turned out to delegate the schedule back
to the SEACE ficha and print no dates at all — confirmed three times, most
recently by the extraction itself returning an empty schedule on three
documents while reading eleven required documents out of one of them
(lib/ingestion/README.md). Everywhere else the feed already supplies the
dates, so what the extraction actually added was a second schedule beside the
real one: one Proyectos Estratégicos tender ended up showing two 现场踏勘 and
two 提问截止, the document's being an earlier round of the same procedure
(user, 2026-09-16: 这些标书不需要导入日期,现在导致日期全乱了).
So the extraction now writes requirements, experience, documents, risks and the
one-line summary, and never a date. findKeyDateProblems()
(lib/ingestion/key-date-checks.ts) survives the removal because the paste tool
uses it: a pasted schedule is still checked against itself and against the
tender's publication_date, since a day/month swap moves one row and leaves the
others where they were.
Rows written by the old extraction are still in the database, still marked
extracted_from_document, and still protected from the importer's refresh.
npm run undo:extracted-key-dates removes them from any tender that also has
hand-entered rows — the case where they are pure noise — and leaves the rest.
Three layers, because they catch different failures:
admin_alerts row and emails
WEBHOOK_ALERT_EMAILS (falling back to ADMIN_EMAILS) —
lib/notifications/ops-alert.ts. Deduped per incident, not per run: while
an unresolved row with the same source exists, a repeat writes nothing and
sends nothing, so a digest failing for one subscriber twice a day for a week
is one email rather than fourteen. Dismissing the banner row re-arms it.
That is why a source names the failing thing
(tender-digest:recipient:<id>) and never the moment.CRON_SECRET making every call 401. Each route
records a heartbeat (cron_heartbeats, migration 0041) and /admin shows
an amber banner for any job past its expected interval. A legitimate skip
(notifications off, digest fired outside its slot) still writes a heartbeat
with status skipped — the job ran and had nothing to do, which is exactly
what has to be told apart from never running. A dry-run purge writes none,
so running one by hand cannot mask a dead scheduler.The amber banner has no dismiss control: it reports a state that is still true and clears itself when the job next runs. The red one reports events, which is why those are acknowledged by hand.
Sending a real test email without waiting for a cron: /admin/email-preview
has buttons for the digest, the renewal reminder and the enterprise invitation
— all to the signed-in admin's own address, rate-limited to 3/hour, and the
invitation writes no enterprise_members row. /admin/billing has the Stripe
webhook alert test. The invitation is there because it is otherwise unreachable
for staff: sending a real one requires an enterprise owner, and an admin
deliberately is not one (see "Admins on the front end").