Synaplan
开源 AI 软件平台,面向企业:自托管 AI 代理,符合 GDPR 的聊天机器人小部件,RAG 知识库,WhatsApp 与 Outlook AI,MCP,多模型路由。支持 Docker、Kubernetes 或在 Germ 托管。
AI 翻译
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证Local LLM pipeline for hotel reviews in Spanish: sentiment (mRoBERTa ensemble), RAG with hotel fact sheets and policies, reply generation (Qwen 27B), guardrails and a calibrated LLM judge (Gemma) with anti-sycophancy tests. Human review queue + Gradio demo. Runs on an AMD GPU (ROCm).
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗English · Español
Fco Javier Fernández Rumí · LinkedIn · GitHub (@JaviRomano)
A pipeline for a hotel chain that classifies the sentiment of a review (negative, neutral, positive) and drafts a professional, personalised reply. The models run locally on an AMD GPU: no review goes through an external model. The data and the replies are in Spanish (Spain).
It is a rebuild of a first attempt (in legacy/, tag v1-original) whose metrics were not valid. See why it was rebuilt.
Guided tour of the demo page, which replays saved results. Reviews and replies stay in Spanish, the language the system works in.
| Phase | Content | Status |
|---|---|---|
| 1. Environment | uv + lockfile, absolute paths, seeds, tests, AMD GPU with ROCm on WSL2 | Done |
| 2. Data | SINAI corpora in Spanish from Spain, normalisation, deduplication, leak-free split | Done |
| 3. Classifier | TF-IDF baseline, mRoBERTa fine-tuning, zero-shot LLM and realistic evaluation | Done (results) |
| 4. Generation | Reply with a local LLM, aspect extraction as JSON, four prompt versions | Done, first iteration (results) |
| 5. RAG and guardrails | Hotel fact sheet, retrieved policies and approved replies; judge with the fact sheet; checks against the fact sheet; human review queue | Done, first iteration (RAG, pipeline) |
| 6. Evaluation | LLM judge with a binary rubric, canaries, position bias and human calibration; used as the regression gate in Phase 5 | Done, first iteration (results); the human calibration is small (24 replies, see its limits) |
| 7. Distillation (optional) | LoRA on a small model to replace the 27B generator | Not done: out of scope for now, the 27B model runs locally fast enough on the GPU |
| 8. Service | End-to-end pipeline (CLI and CSV batch) and Gradio demo with a review queue | Done (demo) |
| 9. Demo and hardening | Live demo with real people, new guardrails from what they wrote, trap detection, latency (75 → 49 s per review), asynchronous judge, replay mode, prompt regression and blind human comparison |
Each one comes from a report generated by a script; the samples are small and the reports give their limits.
| Figure | Source | |
|---|---|---|
| Value: drafts, not auto-publishing (a person decides) | In the pipeline, 8 of 54 reviews (14 synthetic, 40 real) pass every check, all of them positive; negative reviews always go to a person. In a separate blind comparison on 30 new reviews, a person rated 8 of 30 replies publishable as they stood | phase5_pipeline.md, phase9_ciegas.md |
| Latency per review (median, 15 runs, local) | 75.1 s → 49.0 s after fixing a double model load: the analysis stage went from 23.6 to 6.1 s and the judge from 12.8 to 3.6 s. With the asynchronous judge, the draft is on screen at 35.6 s | phase9_latencia.md |
| Judge as a failure detector (24 replies labelled blind by a person) | Per reply: precision 57 % (4 of the 7 it fails, the person fails too), recall 100 % (4 of 4). On "no admission of blame", Cohen's kappa 0.70. But it misses the covert promise (0/1) and 3 of 5 real generator failures by Claude's labels (2 of 3 by a person's) | phase6_calibracion.md, phase6_juez.md |
| Cost per review | 0 external API calls. About 48 s of GPU time (two-thirds of it loading models), ~3,500 input and ~570 output tokens over 3 LLM calls (a 4th, the trap detector, was added after the measurement). Energy was not measured, so no cost in euros is given |
The original project reported an F1 score that did not hold up:
reports/data_report.md (§5).Academic corpora from the SINAI group (University of Jaén), in Spanish from Spain and with all five ratings:
| Corpus | Content | Use |
|---|---|---|
| COAH | 1,816 reviews of Andalusian hotels (TripAdvisor) | Classifier, main test set |
| COAR | 4,932 reviews of 97 Andalusian restaurants (TripAdvisor) | Extra training data and out-of-domain test |
| SFU-Review-SP-Neg | Hotel reviews from ciao.es with negation annotated | Robustness test |
| Full AHR (Kaggle, CC BY-NC 4.0) | 11,414 reviews of 703 hotels (TripAdvisor 2021), after removing duplicates and anything overlapping COAH/COAR | Large out-of-distribution test set; never used for training |
Licences: CC BY-NC-SA 4.0 (SINAI) and CC BY-NC 4.0 (AHR); non-commercial use. The data is not in the repository: it is downloaded at a pinned commit and git only keeps its hash (data/raw/SHA256SUMS). The one exception is data/raw/Balanced_AHR.csv (CC BY-NC 4.0): the dataset of the original project, kept so the leakage audit can be reproduced; the rebuild does not train on it. COAH and COAR are gated: you must accept their terms on Hugging Face with your account.
The data pipeline (reports/data_report.md records every step with its figures):
Macro F1 (negative / neutral / positive). The figures come from the reports in reports/, generated by the scripts.
| Model | Clean test: COAH (269) | Large test: AHR (11,414) | AHR, paired sample (2,000) | Another site: SFU (50, accuracy) |
|---|---|---|---|---|
| TF-IDF + logistic regression | 0.750 | — | — | — |
| mRoBERTa, fine-tuned (3-seed ensemble) | 0.797 | 0.781 (95 % CI 0.770–0.791) | 0.750 | 78 % |
| Qwen3.6 27B, zero-shot (no training) | 0.748 | — | 0.771 | 90 % |
What the evaluation taught (baseline, mRoBERTa, realistic, zero-shot):
qwen3.6:27b, run locally (temperature 0), first extracts the review's aspects as JSON, each with a verbatim quote, and then drafts the reply. The development set is 40 real COAH reviews (8 per rating) and 8 synthetic adversarial cases: false premise, liability bait, refund demand, prompt injection and Andalusian dialect. Report: reports/phase4_generacion.md.
spec.py) that also generates the judge's rules, and a test keeps them from diverging.The judge is gemma4:12b (a different family from the generator, to avoid self-preference) with a rubric of 8 binary criteria and a verbatim quote as evidence for every fail: concrete aspect, no promises, no admission of blame, no confirmed premises, no invention, no exaggeration, register (formal "usted", standard Spanish) and tone. Reports: reports/phase6_juez.md and reports/phase6_calibracion.md.
| Test | Rubric v1 | v2 | v3 |
|---|---|---|---|
| Canaries (deliberately bad replies) failed | 19/19 | 19/19 | 19/19 |
| Controls (correct replies) with no fail | 9/11 | 10/11 | 11/11 |
| Real generator failures detected (gold seeds, Claude's labels) | 2/5 | 2/5 | 1/5 |
| Human calibration · kappa on "no admission of blame" | 0.22 | 0.70 | 0.62 |
| Human calibration · kappa on "no promises" | 0.00 | 0.00 | 0.00 |
What it taught:
Calibration limits. 24 replies with 4 human fails have no statistical power: the kappas are a hint, not proof. Also, the human labels were corrected after seeing the results, based on disagreements with a second blind annotator (an LLM in a clean context, with no access to the judge or the human labels). The corrections are noted row by row, and the first pass is kept in
calibracion_primera_pasada.csv.
Design conclusion: the judge works as a regression test for prompt or model changes and to flag suspicious sentences for the reviewer, not as permission to publish without human review. Besides, without the hotel's fact sheet, "they promised us a free spa" cannot be checked against anything: that is what RAG solves.
A review arrives with its hotel, so the fact sheet is not searched for: it goes into the prompt in full. What is retrieved by search are the policies that apply to the review (compensation, incidents, complaints…) and approved replies to similar reviews, as examples. The knowledge base is synthetic (kb/): 8 fact sheets of fictitious hotels, 18 policies and 16 approved replies, drafted by Claude; the author reviewed the approved replies. Reports: retrieval, generation and judge.
Retrieval (30 labelled queries; recall@3 with 95 % CI). BM25 and bge-m3 via Ollama, in memory and fused with RRF; with a few dozen documents, a vector database adds nothing.
| Index | BM25 | Dense (bge-m3) | Hybrid (RRF) |
|---|---|---|---|
| Policies (17) | 0.69 (0.53–0.83) | 0.77 (0.64–0.89) | 0.77 (0.63–0.89) |
| Approved replies (16) | 0.64 (0.46–0.80) | 0.91 (0.82–0.98) | 0.82 (0.68–0.93) |
For policies, the hybrid search ties the dense one on recall@3 but puts the right policy first more often (MRR 0.82 vs 0.72), which is why it is used. These labels were written by Claude. A person then relabelled all 30 queries blind, with the first pass frozen before comparing (report): agreement is good on policies (kappa 0.85) and moderate on approved replies (0.64). With the person's labels the hybrid's advantage on policies is within noise (MRR 0.78 vs 0.72, 95 % CI of the difference −0.05 to +0.18), and dense search stays ahead for approved replies (MRR 0.84 vs 0.66). The person found no useful approved reply for 14 of the 30 queries, yet the prompt still receives 2 examples: the knowledge base has gaps.
Generation with context. The RAG prompts (v5, v6) are prompt v4 plus a block on how to use the context, and a test guarantees there is no other difference: this measures the effect of RAG, not of a different prompt. Besides the Phase 4 adversarial cases, there are 6 cases where the fact sheet contradicts or qualifies the guest (what really tests sycophancy: the guest says there were no signs and the fact sheet says there were; they were promised a car park that does not exist).
Judge with the fact sheet (rubric v2h), 54 replies per version:
| v4 (no context) | v5 (RAG) | v6 (RAG, corrected) | |
|---|---|---|---|
| Fails on "no admission of blame" | 16 | 9 | 8 |
| Fails on "neither confirms nor denies" | 3 | 0 | 1 |
| Fact sheet vs. guest: pass every criterion | 2/6 | 4/6 | 3/6 |
| Real reviews: pass every criterion | 31/40 | 34/40 | 36/40 |
These figures are from rubric v2h and the knowledge base as revised on 2026-10-25. A first run (rubric v2g, earlier knowledge base) gave 6 / 2 / 0 blame fails: the stricter rubric and the revised base show that RAG halves implicit blame rather than removing it.
What it taught:
Limits. The knowledge base, the adversarial cases, the relevance labels and the canaries are synthetic and written for the project; there are only 6 fact-sheet-vs-guest cases. Rubrics v2g and v2h were corrected after seeing a result (mitigated by adding a real canary before re-running). No figure in this section has been measured on real reviews and replies from a hotel.
Incident: the model was running on the CPU without warning. An automatic Ollama update removed the AMD support package (ROCm), which on Windows is installed separately, and Ollama fell back to the CPU with no error: each reply from the 27B model took about 130 s instead of 3.5 s. Since then, the scripts that use the LLM check at start-up that the model is fully on the GPU and fail otherwise (ollama_gpu.py).
pipeline.py chains every step: normalisation and PII masking → sentiment with the production classifier (3-seed mRoBERTa ensemble, on CPU) → aspects → RAG → reply (prompt v6) → deterministic checks and the judge with the fact sheet → publish or send to a person, with the reasons. A reply goes to human review if any of these holds: the review is negative or neutral, the classifier's confidence is below 0.85 (threshold chosen on validation), the guest asks for something (a refund, compensation…), a deterministic check fires, or the judge fails a criterion. Report: reports/phase5_pipeline.md.
The deterministic checks were added after reading real replies (Phase 9), each for a failure the judge does not catch:
| Sent to review when… | Why |
|---|---|
| the classifier and the LLM disagree on the sentiment | the classifier does not understand sarcasm (a sarcastic complaint came out positive, 0.93) |
| the review looks like a trap (a joke, a provocation, instructions to the system) | an LLM check before generating; the reply is a brief, polite ironic note |
| the reply uses a person's name from the review (except the customer's own name, in the greeting, if they gave it) | names without a title are not masked yet |
| the reply mentions hygiene or smells | it must not suggest the hotel had a hygiene problem |
| the reply copies 4+ words from the review | it sounds like a parrot |
| the reply talks about "your stay" when the guest never stayed | the judge misses it |
| the guest claims a service the fact sheet says does not exist | a person checks the reply that informs them |
| the review mentions an accident, an injury or a legal claim | the judge passed replies that hinted at blame ("to guarantee the safety of all our guests"), and before this rule such reviews went to a person only if they were negative |
reports/phase9_latencia.md), most of it loading models, because the 27B generator and the 12B judge do not fit in VRAM together and Ollama swaps them. The demo shows the draft at about 36 s and the judge's verdict when it arrives. In batch mode the pipeline runs in stages (classify all, retrieve all, generate all, judge all) to avoid that.Later versions were tried to fix what reading the replies shows (repeated openings, copied phrases, "we will review this in order to…", which hints at a fault). None was adopted. (v7, v6 plus a rule against assuming a stay that never happened, ties with it in reports/phase5_juez.md: 36/40 real reviews pass everything with both.)
reports/phase9_regresion.md);reports/phase9_ciegas.md; the reviews and ratings stay out of git);The Gradio demo (uv run just app) shows each step for one review and has a review queue where a person approves, edits or discards each draft. The draft appears as soon as it is written and the judge's verdict arrives a few seconds later; nothing is shown as publishable before it. The hotels and their fact sheets are fictitious. Reviews written in the live demo, and the reviewers' decisions, are stored outside git (data/live/).
Replay mode, online: javiromano.github.io/Spanish-hotel-review-LLM (in Spanish). A static page with the pre-computed results of 15 reviews (6 written for the demo, with what a good reply should do written beforehand, and 9 illustrative cases, with notes on their failures): no models, GPU or server. It is generated by uv run just replay into docs/index.html. A live test is run on request. Walkthrough and checklist (in Spanish): docs/DEMO.md.
qwen3.6:27b-q4_K_M (generator and zero-shot baseline), gemma4:12b-it-qat (evaluation judge) and bge-m3 (RAG embeddings).ollama-windows-amd64-rocm.zip of the same version, extracted into the Ollama folder; every update leaves it outdated or removes it, so it is worth disabling automatic updates. Check: uv run just gpu-ollama.train group installs ROCm torch on Linux only. Data, baseline and tests run on Windows without a GPU.serve group installs CPU torch, transformers and Gradio. The classifier weights are not in the repository: train them with uv run just train --options A --save and copy models/mroberta/ next to the code.amdgpu-install -y --usecase=rocm --no-dkms. The --usecase=wsl option no longer exists since ROCm 7.2.1./opt/rocm. It needs the Windows SDK 10.0.26100 headers, which can be taken from the Microsoft.Windows.SDK.CPP NuGet package without installing the SDK.~/.profile: HSA_ENABLE_DXG_DETECTION=1 and ROCPROFILER_REGISTER_ENABLED=0. Without the second one, the profiler bundled with torch aborts because it looks for /sys/class/kfd./mnt/c or /mnt/d: a Linux environment on the Windows folder breaks the Windows .venv.uv run just gpu.uv sync # environment (on Linux it includes ROCm torch)
uv run hf auth login # once; first accept the COAH and COAR terms on Hugging Face
uv run just data-download # downloads the corpora to data/raw/
uv run just data # data pipeline -> data/processed/ and reports/data_report.md
uv run just test # tests (including that data/raw has not been modified)
uv run just baseline # Phase 3: TF-IDF + logistic regression
uv run just train # Phase 3: mRoBERTa fine-tuning (WSL2 + ROCm; --save keeps weights)
uv run just realista # Phase 3: stress, SFU, AHR and coverage vs. error (WSL2)
uv run just zeroshot # Phase 3: zero-shot LLM and pressure test (Ollama)
uv run just generar # Phase 4: aspects and replies (--prompt v1|v2|v3|v4)
uv run just juez # Phase 6: canaries, controls, gold and pairwise comparison
uv run just calibrar # Phase 6: judge kappa against the human labels
uv run just rag # Phase 5: retrieval (BM25, bge-m3, hybrid) with recall@k and MRR
uv run just rag-generar # Phase 5: replies with context (--prompt v5|v6) vs. v4
uv run just rag-juez # Phase 5: judge with fact sheet, canary gate and pairs
uv run just gpu-ollama # checks that Ollama has the whole model on the GPU
uv run just pipeline # full pipeline on the demo set (--csv file.csv for your own reviews)
uv run just app # Gradio demo with review queue (http://127.0.0.1:7860)
uv run just app-live # the same, password-protected, for a live demo with other people
uv run just replay # rebuilds the static replay page (docs/index.html) and opens it
uv run just recoger # form to collect reviews only (no models); answered later in batch
uv run just latencia # Phase 9: time per stage (loading vs inference) -> reports/phase9_latencia.md
uv run just regresion # Phase 9: prompt versions under the same conditions, safety filter and pairs
uv run just variedad # Phase 9: openings, unique word pairs and review details in a run, no models
uv run just modelo-pequeno # Phase 9: smaller generator (qwen3.5:9b) against the 27B
uv run just check-raw # verifies the data/raw hashes
uv run just lint # ruff check + format --check
uv run python scripts/acuerdo_anotadores.py # agreement with the second annotator
uv run python scripts/calibracion_vista.py # HTML sheet for blind labelling
uv run python scripts/comparacion_ciegas.py # blind A/B sheet of two prompt versions and its result
uv run python scripts/formulario_tandas.py # HTML form to write test reviews by hand, 10 at a time
uv run python scripts/figuras.py # README charts from the versioned results
uv run --with playwright python scripts/capturas.py # demo screenshots (with the app running)
uv run --with playwright python scripts/verificar_replay.py # replay page: screenshots, console, AA contrast, keyboard
uv run just # lists every recipe
The scripts print their progress to the console and also write it to logs/<script>.log.
src/caso_uso_llm/
data/ download, reading, normalisation (rules R1–R17), deduplication and split
classify/ baseline, fine-tuning, realistic evaluation, zero-shot and metrics with CIs
generate/ versioned prompts, JSON aspect schema and deterministic checks
evals/ judge, versioned rubrics (with and without fact sheet) and calibration
rag/ knowledge base, hybrid retrieval, generation with context and its judge
pipeline.py end-to-end pipeline and routing to the review queue · app.py Gradio demo
spec.py single specification that produces the prompt and the rubric
ollama_gpu.py fails if Ollama does not have the whole model on the GPU
timing.py · latency.py per-stage timing (off unless enabled) and the latency report
paths.py absolute paths · seed.py seeds · log.py progress
kb/ synthetic knowledge base: hotel fact sheets, policies and approved replies
evals/ adversarial cases, canaries, gold, retrieval queries, calibration labels and the latency sample
scripts/ utilities (labelling and comparison sheets, forms, agreement, GPU, charts, screenshots)
docs/ replay page (index.html, GitHub Pages), demo guide (DEMO.md), README charts and screenshots (img/)
tests/ normalisation, deduplication, split, data invariants, prompt-rubric consistency, routing and the app
reports/ reports generated by the scripts (not edited by hand)
results/ results as JSON (including predictions, to regenerate reports)
data/raw/ immutable raw data, verified by hash
data/live/ (not in git) reviews written by other people in the live demo and everything derived from them
legacy/ original project, frozen
uv.lock, fixed seeds and datasets downloaded at a pinned commit.reports/ generated by a script; each report states the command that regenerates it. The exception is the input of the human evaluations: the blind comparison (reports/phase9_ciegas.md) is regenerated from reviews and ratings kept outside git, because they were written by other people; the report only holds the counts.This project was built with Claude Code, so it is worth saying who did what.
What I decided and validated
What was done with Claude's assistance
Co-Authored-By: Claude.calibracion_primera_pasada.csv.revision_kb.csv).The project's figures come from versioned scripts and can be regenerated.
The code is under the MIT licence. Third-party data and material derived from it keep their own licences: CC BY-NC-SA 4.0 (SINAI corpora) and CC BY-NC 4.0 (AHR), including the review excerpts quoted in reports/, results/ and evals/.
The code of the first attempt is in legacy/ as audited (tag v1-original). It is neither run nor modified; it is kept for comparison. Its source dataset (Balanced_AHR.csv, from the Andalusian Hotels' Reviews corpus, CC BY-NC 4.0) is in data/raw/.
English · Español
Fco Javier Fernández Rumí · LinkedIn · GitHub (@JaviRomano)
Pipeline para una cadena hotelera que clasifica el sentimiento de una reseña (negativo, neutral, positivo) y redacta una respuesta profesional y personalizada. Los modelos se ejecutan en local, sobre una GPU AMD: ninguna reseña pasa por un modelo externo.
Es la reconstrucción de un primer intento (en legacy/, etiqueta v1-original) cuyas métricas no eran válidas. Ver por qué se rehízo.
Recorrido guiado de la página de demostración, que repite resultados ya guardados.
| Fase | Contenido | Estado |
|---|---|---|
| 1. Entorno | uv + lockfile, rutas absolutas, semillas, tests, GPU AMD con ROCm en WSL2 | Hecho |
| 2. Datos | Corpus de SINAI en español de España, normalización, deduplicación, split sin fuga | Hecho |
| 3. Clasificador | Baseline TF-IDF, fine-tuning de mRoBERTa, LLM zero-shot y evaluación realista | Hecho (resultados) |
| 4. Generación | Respuesta con un LLM local, extracción de aspectos en JSON, cuatro versiones del prompt | Hecho, primera iteración (resultados) |
| 5. RAG y guardarraíles | Ficha del hotel, políticas y respuestas aprobadas recuperadas; juez con ficha; comprobaciones contra la ficha; cola de revisión humana | Hecho, primera iteración (RAG, pipeline) |
| 6. Evaluación | Juez LLM con rúbrica binaria, canaries, sesgo de posición y calibración humana; usado como prueba de regresión en la Fase 5 | Hecho, primera iteración (resultados); la calibración humana es pequeña (24 respuestas, ver sus límites) |
| 7. Destilación (opcional) | LoRA sobre un modelo pequeño para sustituir al generador de 27B | No hecha: fuera de alcance por ahora, el modelo de 27B ya va rápido en local con la GPU |
| 8. Servicio | Pipeline completo (línea de comandos y lotes con CSV) y demo en Gradio con cola de revisión | Hecho (demo) |
Todas salen de un informe generado por script; las muestras son pequeñas y los informes indican sus límites.
| Cifra | Fuente | |
|---|---|---|
| Valor: borradores, no autopublicación (decide una persona) | En el pipeline, 8 de 54 reseñas (14 sintéticas y 40 reales) pasan todos los controles, todas positivas; las negativas van siempre a una persona. En una comparación a ciegas aparte, con 30 reseñas nuevas, una persona dio 8 de 30 respuestas como publicables tal cual | phase5_pipeline.md, phase9_ciegas.md |
| Latencia por reseña (mediana, 15 medidas, en local) | 75,1 s → 49,0 s al corregir una doble carga de los modelos: la etapa de análisis pasó de 23,6 a 6,1 s y la del juez de 12,8 a 3,6 s. Con el juez asíncrono, el borrador se ve a los 35,6 s | phase9_latencia.md |
| El juez como detector de fallos (24 respuestas etiquetadas a ciegas por una persona) | Por respuesta: precisión 57 % (de las 7 que suspende, la persona suspende 4), recall 100 % (4 de 4). En «no admite culpa», kappa de Cohen 0,70. Pero no ve la promesa encubierta (0/1) ni 3 de 5 fallos reales del generador según las etiquetas de Claude (2 de 3 según las de una persona) | phase6_calibracion.md, phase6_juez.md |
| Coste por reseña | 0 llamadas a APIs externas. Unos 48 s de GPU (dos tercios en cargar modelos), ~3.500 tokens de entrada y ~570 de salida en 3 llamadas al LLM (una 4.ª, el detector de trampas, se añadió después de medir). No se midió el consumo eléctrico, así que no se da un coste en euros |
El proyecto original reportaba un F1 que no se sostenía:
reports/data_report.md (§5).Corpus académicos del grupo SINAI (Universidad de Jaén), en español de España y con las cinco puntuaciones:
| Corpus | Contenido | Uso |
|---|---|---|
| COAH | 1.816 reseñas de hoteles andaluces (TripAdvisor) | Clasificador, test principal |
| COAR | 4.932 reseñas de 97 restaurantes andaluces (TripAdvisor) | Entrenamiento adicional y test fuera de dominio |
| SFU-Review-SP-Neg | Reseñas de hoteles de ciao.es con la negación anotada | Prueba de robustez |
| AHR completo (Kaggle, CC BY-NC 4.0) | 11.414 reseñas de 703 hoteles (TripAdvisor 2021), tras quitar duplicados y todo lo que coincide con COAH/COAR | Test grande fuera de distribución; nunca se entrena con él |
Licencias CC BY-NC-SA 4.0 (SINAI) y CC BY-NC 4.0 (AHR); uso no comercial. Los datos no se incluyen en el repositorio: se descargan con un commit fijado y en git solo se guarda su hash (data/raw/SHA256SUMS). La única excepción es data/raw/Balanced_AHR.csv (CC BY-NC 4.0): el dataset del proyecto original, que se conserva para poder reproducir la auditoría de la fuga; el remake no entrena con él. COAH y COAR son gated: hay que aceptar sus términos en Hugging Face con tu cuenta.
El pipeline de datos (reports/data_report.md recoge cada paso con sus cifras):
F1 macro (negativo / neutral / positivo). Las cifras salen de los informes de reports/, generados por los scripts.
| Modelo | Test limpio: COAH (269) | Test grande: AHR (11.414) | AHR, muestra pareada (2.000) | Otra web: SFU (50, acierto) |
|---|---|---|---|---|
| TF-IDF + regresión logística | 0,750 | — | — | — |
| mRoBERTa, fine-tuning (ensamble de 3 semillas) | 0,797 | 0,781 (IC 95 % 0,770–0,791) | 0,750 | 78 % |
| Qwen3.6 27B, zero-shot (sin entrenar) | 0,748 | — | 0,771 | 90 % |
Lo que enseñó la evaluación (baseline, mRoBERTa, realista, zero-shot):
qwen3.6:27b en local (temperatura 0) extrae primero los aspectos de la reseña en JSON, cada uno con una cita literal, y después redacta la respuesta. El conjunto de desarrollo son 40 reseñas reales de COAH (8 por puntuación) y 8 casos adversariales sintéticos: premisa falsa, cebo de responsabilidad, petición de reembolso, inyección de instrucciones y dialecto andaluz. Informe: reports/phase4_generacion.md.
spec.py) de la que se generan también las reglas del juez, y un test impide que diverjan.Juez gemma4:12b (otra familia que el generador, para evitar la autopreferencia) con una rúbrica de 8 criterios binarios y una cita literal como evidencia de cada suspenso: aspecto concreto, sin promesas, sin admitir culpa, sin confirmar premisas, sin inventar, sin exagerar, registro (de usted, español estándar) y tono. Informes: reports/phase6_juez.md y reports/phase6_calibracion.md.
| Prueba | Rúbrica v1 | v2 | v3 |
|---|---|---|---|
| Canaries (respuestas malas a propósito) suspendidos | 19/19 | 19/19 | 19/19 |
| Controles (respuestas correctas) sin ningún suspenso | 9/11 | 10/11 | 11/11 |
| Fallos reales del generador detectados (semillas del gold, etiquetas de Claude) | 2/5 | 2/5 | 1/5 |
| Calibración humana · kappa en «sin admitir culpa» | 0,22 | 0,70 | 0,62 |
| Calibración humana · kappa en «sin promesas» | 0,00 | 0,00 | 0,00 |
Lo que enseñó:
Límites de la calibración. 24 respuestas con 4 suspensos humanos no tienen potencia estadística: las kappas son un indicio, no una prueba. Además, las etiquetas humanas se corrigieron después de ver los resultados, a partir de los desacuerdos con un segundo anotador a ciegas (un LLM en un contexto limpio, sin acceso al juez ni a las etiquetas humanas). Las correcciones están anotadas fila a fila, y la primera pasada se conserva en
calibracion_primera_pasada.csv.
Conclusión de diseño: el juez sirve como prueba de regresión ante cambios de prompt o modelo y para señalar frases sospechosas a quien revisa, no como permiso para publicar sin revisión humana. Sin ficha del hotel, además, «nos prometieron spa gratis» no se puede contrastar con nada: eso lo resuelve el RAG.
La reseña llega con su hotel, así que la ficha no se busca: entra completa en el prompt. Lo que se recupera por búsqueda son las políticas que aplican a la reseña (compensaciones, incidentes, reclamaciones…) y respuestas aprobadas a reseñas parecidas, como ejemplos. La base de conocimiento es sintética (kb/): 8 fichas de hoteles ficticios, 18 políticas y 16 respuestas aprobadas, redactadas por Claude; el autor revisó las respuestas aprobadas. Informes: recuperación, generación y juez.
Recuperación (30 consultas etiquetadas; recall@3 con IC 95 %). BM25 y bge-m3 vía Ollama, en memoria y fusionados con RRF; con unas decenas de documentos, una base vectorial no aporta nada.
| Índice | BM25 | Densa (bge-m3) | Híbrida (RRF) |
|---|---|---|---|
| Políticas (17) | 0,69 (0,53–0,83) | 0,77 (0,64–0,89) | 0,77 (0,63–0,89) |
| Respuestas aprobadas (16) | 0,64 (0,46–0,80) | 0,91 (0,82–0,98) | 0,82 (0,68–0,93) |
En políticas, la híbrida empata con la densa en recall@3, pero pone la política correcta primero más a menudo (MRR 0,82 frente a 0,72), y por eso es la que se usa. Estas etiquetas las escribió Claude. Después, una persona volvió a etiquetar las 30 consultas a ciegas, con la primera pasada congelada antes de comparar (informe): el acuerdo es bueno en políticas (kappa 0,85) y moderado en respuestas aprobadas (0,64). Con las etiquetas de la persona, la ventaja de la híbrida en políticas queda dentro del ruido (MRR 0,78 frente a 0,72; IC 95 % de la diferencia, de −0,05 a +0,18), y la densa sigue por delante en respuestas aprobadas (MRR 0,84 frente a 0,66). La persona no encontró ninguna respuesta aprobada útil para 14 de las 30 consultas, y aun así el prompt recibe 2 ejemplos: la base de conocimiento tiene huecos.
Generación con contexto. Los prompts con RAG (v5, v6) son el prompt v4 más un bloque sobre cómo usar el contexto, y un test garantiza que no hay otra diferencia: así se mide el efecto del RAG y no el de otro prompt. Además de los casos adversariales de la Fase 4, hay 6 casos en los que la ficha contradice o matiza al cliente (lo que de verdad pone a prueba la sycophancy: dice que no había carteles y la ficha dice que sí; le prometieron un aparcamiento que no existe).
Juez con ficha (rúbrica v2h), 54 respuestas por versión:
| v4 (sin contexto) | v5 (RAG) | v6 (RAG corregido) | |
|---|---|---|---|
| Suspensos en «no admite culpa» | 16 | 9 | 8 |
| Suspensos en «no confirma ni niega» | 3 | 0 | 1 |
| Ficha contra cliente: cumplen todos los criterios | 2/6 | 4/6 | 3/6 |
| Reseñas reales: cumplen todos los criterios | 31/40 | 34/40 | 36/40 |
Estas cifras son de la rúbrica v2h y de la base de conocimiento revisada el 2026-10-25. Una primera ejecución (rúbrica v2g, base anterior) daba 6 / 2 / 0 suspensos de culpa: con la rúbrica más estricta y la base revisada se ve que el RAG reduce la culpa implícita a la mitad, no que la elimine.
Lo que enseñó:
Límites. La base de conocimiento, los casos adversariales, las etiquetas de relevancia y los canaries son sintéticos y escritos para el proyecto; los casos de ficha contra cliente son 6. Las rúbricas v2g y v2h se corrigieron después de ver un resultado (mitigado con un canary real añadido antes de repetir). Ninguna cifra de esta sección se ha medido con reseñas y respuestas reales de un hotel.
Incidente: el modelo corría en la CPU sin avisar. Una actualización automática de Ollama borró el paquete de soporte AMD (ROCm), que en Windows se instala aparte, y Ollama pasó a la CPU sin ningún error: cada respuesta del modelo de 27B tardaba unos 130 s en vez de 3,5 s. Desde entonces, los scripts que usan el LLM comprueban al arrancar que el modelo está entero en la GPU y fallan si no (ollama_gpu.py).
pipeline.py encadena todos los pasos: normalización y enmascarado de datos personales → sentimiento con el clasificador de producción (ensamble de 3 semillas de mRoBERTa, en CPU) → aspectos → RAG → respuesta (prompt v6) → comprobaciones deterministas y juez con ficha → publicar o pasar a una persona, con los motivos. Una respuesta va a revisión humana si se cumple cualquiera de estas condiciones: la reseña es negativa o neutral, la confianza del clasificador es menor que 0,85 (umbral elegido en validación), el cliente pide algo (un reembolso, una compensación…), salta una comprobación determinista o el juez suspende algún criterio. Informe: reports/phase5_pipeline.md.
Las comprobaciones deterministas se añadieron leyendo respuestas reales (Fase 9), cada una para un fallo que el juez no detecta:
| Va a revisión si… | Por qué |
|---|---|
| el clasificador y el LLM no coinciden en el sentimiento | el clasificador no entiende el sarcasmo (una queja sarcástica salió positiva, 0,93) |
| la reseña parece una trampa (una broma, una provocación, instrucciones al sistema) | un LLM lo comprueba antes de generar; la respuesta es una nota irónica breve y cortés |
| la respuesta usa un nombre de persona de la reseña (salvo el del propio cliente en el saludo, si lo dio) | los nombres sin tratamiento ni cargo aún no se enmascaran |
| la respuesta menciona higiene u olores | no debe sugerir que el hotel tuvo un problema de higiene |
| la respuesta copia 4 o más palabras de la reseña | suena a loro |
| la respuesta habla de «su estancia» cuando el cliente no llegó a alojarse | el juez no lo ve |
| el cliente afirma un servicio que la ficha dice que no existe | una persona revisa la respuesta que se lo aclara |
| la reseña menciona un accidente, una lesión o una reclamación legal | el juez aprobó respuestas que sugerían culpa («para garantizar la seguridad de todos nuestros huéspedes»), y antes de esta regla esas reseñas solo iban a una persona si eran negativas |
reports/phase9_latencia.md), la mayor parte en cargar modelos, porque el generador de 27B y el juez de 12B no caben juntos en la VRAM y Ollama los alterna. La demo muestra el borrador a los ~36 s y el veredicto del juez cuando llega. Por lotes, el pipeline va por etapas (clasificar todo, recuperar todo, generar todo, juzgar todo) para evitarlo.Se probaron varias versiones para corregir lo que muestra la lectura de las respuestas: aperturas repetidas, frases copiadas y «revisaremos lo ocurrido para…», que sugiere un fallo. No se adoptó ninguna. (v7, que es v6 más una regla para no presuponer una estancia que no hubo, empata con v6 en reports/phase5_juez.md: 36/40 reseñas reales cumplen todo con las dos.)
reports/phase9_regresion.md);reports/phase9_ciegas.md; las reseñas y las valoraciones quedan fuera de git);La demo en Gradio (uv run just app) muestra cada paso para una reseña y tiene una cola de revisión donde una persona aprueba, edita o descarta cada borrador. El borrador aparece en cuanto está escrito y el veredicto del juez llega unos segundos después; antes de él no se muestra nada como publicable. Los hoteles y sus fichas son ficticios. Las reseñas escritas en la demo en vivo y las decisiones de quien revisa se guardan fuera de git (data/live/).
Modo repetición, en línea: javiromano.github.io/Spanish-hotel-review-LLM. Una página estática con los resultados ya calculados de 15 reseñas (6 escritas para la demo, con lo que debía hacer una buena respuesta escrito antes, y 9 casos ilustrativos, con notas sobre sus fallos): sin modelos, GPU ni servidor. La genera uv run just replay en docs/index.html. La prueba en vivo se hace a petición. Recorrido y lista de comprobación: docs/DEMO.md.
qwen3.6:27b-q4_K_M (generador y referencia zero-shot), gemma4:12b-it-qat (juez de las evaluaciones) y bge-m3 (embeddings del RAG).ollama-windows-amd64-rocm.zip de la misma versión, extraído en la carpeta de Ollama; cada actualización lo deja desfasado o lo borra, así que conviene desactivar la actualización automática. Comprobación: uv run just gpu-ollama.train instala torch para ROCm solo en Linux. Datos, baseline y tests funcionan en Windows sin GPU.serve instala torch para CPU, transformers y Gradio. Los pesos del clasificador no están en el repositorio: se entrenan con uv run just train --options A --save y se copia models/mroberta/ junto al código.amdgpu-install -y --usecase=rocm --no-dkms. La opción --usecase=wsl ya no existe desde ROCm 7.2.1./opt/rocm. Necesita las cabeceras del Windows SDK 10.0.26100, que pueden sacarse del paquete NuGet Microsoft.Windows.SDK.CPP sin instalar el SDK.~/.profile: HSA_ENABLE_DXG_DETECTION=1 y ROCPROFILER_REGISTER_ENABLED=0. Sin la segunda, el profiler que trae torch aborta porque busca /sys/class/kfd./mnt/c o /mnt/d: un entorno de Linux sobre la carpeta de Windows rompe el .venv de Windows.uv run just gpu.uv sync # entorno (en Linux incluye torch ROCm)
uv run hf auth login # una vez; acepta antes los términos de COAH y COAR en Hugging Face
uv run just data-download # descarga los corpus a data/raw/
uv run just data # pipeline de datos -> data/processed/ y reports/data_report.md
uv run just test # tests (incluye que data/raw no se ha modificado)
uv run just baseline # Fase 3: TF-IDF + regresión logística
uv run just train # Fase 3: fine-tuning de mRoBERTa (WSL2 + ROCm; --save guarda pesos)
uv run just realista # Fase 3: estrés, SFU, AHR y cobertura frente a error (WSL2)
uv run just zeroshot # Fase 3: LLM zero-shot y prueba de presión (Ollama)
uv run just generar # Fase 4: aspectos y respuestas (--prompt v1|v2|v3|v4)
uv run just juez # Fase 6: canaries, controles, gold y comparación por pares
uv run just calibrar # Fase 6: kappa del juez frente a las etiquetas humanas
uv run just rag # Fase 5: recuperación (BM25, bge-m3, híbrida) con recall@k y MRR
uv run just rag-generar # Fase 5: respuestas con contexto (--prompt v5|v6) frente a v4
uv run just rag-juez # Fase 5: juez con ficha, puerta de canaries y pares
uv run just gpu-ollama # comprueba que Ollama tiene el modelo entero en la GPU
uv run just pipeline # pipeline completo con el conjunto de demostración (--csv fichero.csv para tus reseñas)
uv run just app # demo en Gradio con cola de revisión (http://127.0.0.1:7860)
uv run just app-live # lo mismo con contraseña, para una demo en vivo con otras personas
uv run just replay # regenera la página estática de repetición (docs/index.html) y la abre
uv run just recoger # solo un formulario para recoger reseñas (sin modelos); se responden después por lotes
uv run just latencia # Fase 9: tiempo por etapa (carga frente a inferencia) -> reports/phase9_latencia.md
uv run just regresion # Fase 9: versiones del prompt en las mismas condiciones, filtro de seguridad y pares
uv run just variedad # Fase 9: aperturas, pares de palabras únicos y detalles de la reseña, sin modelos
uv run just modelo-pequeno # Fase 9: generador más pequeño (qwen3.5:9b) frente al de 27B
uv run just check-raw # verifica los hashes de data/raw
uv run just lint # ruff check + format --check
uv run python scripts/acuerdo_anotadores.py # acuerdo con el segundo anotador
uv run python scripts/calibracion_vista.py # hoja HTML para etiquetar a ciegas
uv run python scripts/comparacion_ciegas.py # hoja A/B a ciegas de dos versiones del prompt y su resultado
uv run python scripts/formulario_tandas.py # formulario HTML para escribir reseñas de prueba, de 10 en 10
uv run python scripts/figuras.py # gráficas del README a partir de los resultados versionados
uv run --with playwright python scripts/capturas.py # capturas de la demo (con la app en marcha)
uv run --with playwright python scripts/verificar_replay.py # página de repetición: capturas, consola, contraste AA y teclado
uv run just # lista todas las recetas
Los scripts muestran su progreso en consola y lo escriben también en logs/<script>.log.
src/caso_uso_llm/
data/ descarga, lectura, normalización (reglas R1–R17), deduplicación y split
classify/ baseline, fine-tuning, evaluación realista, zero-shot y métricas con IC
generate/ prompts versionados, esquema JSON de aspectos y comprobaciones deterministas
evals/ juez, rúbricas versionadas (con y sin ficha) y calibración
rag/ base de conocimiento, recuperación híbrida, generación con contexto y su juez
pipeline.py pipeline completo y enrutado a la cola de revisión · app.py demo en Gradio
spec.py especificación única de la que salen el prompt y la rúbrica
ollama_gpu.py falla si Ollama no tiene el modelo entero en la GPU
timing.py · latency.py medición por etapa (inactiva salvo que se active) e informe de latencia
paths.py rutas absolutas · seed.py semillas · log.py progreso
kb/ base de conocimiento sintética: fichas de hotel, políticas y respuestas aprobadas
evals/ casos adversariales, canaries, gold, consultas de recuperación, etiquetas de calibración y muestra de latencia
scripts/ utilidades (hojas de etiquetado y de comparación, formularios, acuerdo, GPU, gráficas, capturas)
docs/ página de repetición (index.html, GitHub Pages), guía de la demo (DEMO.md), gráficas y capturas (img/)
tests/ normalización, deduplicación, split, invariantes de los datos, coherencia prompt-rúbrica, enrutado y la app
reports/ informes generados por los scripts (no se editan a mano)
results/ resultados en JSON (incluidas las predicciones, para regenerar informes)
data/raw/ datos crudos inmutables, verificados por hash
data/live/ (fuera de git) reseñas escritas por otras personas en la demo en vivo y todo lo derivado de ellas
legacy/ proyecto original, congelado
uv.lock, semillas fijas y datasets descargados por commit.reports/ generado por un script; cada informe indica el comando que lo regenera. La excepción es la entrada de las evaluaciones humanas: la comparación a ciegas (reports/phase9_ciegas.md) se regenera a partir de reseñas y valoraciones que están fuera de git porque las escribieron otras personas; el informe solo guarda los recuentos.Este proyecto se desarrolló con la ayuda de Claude Code, y conviene decir qué parte hizo cada uno.
Qué decidí y validé yo
Qué se hizo con asistencia de Claude
Co-Authored-By: Claude.calibracion_primera_pasada.csv.revision_kb.csv).Las cifras del proyecto salen de scripts versionados y se pueden regenerar.
El código tiene licencia MIT. Los datos de terceros y lo derivado de ellos mantienen sus licencias: CC BY-NC-SA 4.0 (corpus de SINAI) y CC BY-NC 4.0 (AHR), incluidos los fragmentos de reseñas citados en reports/, results/ y evals/.
El código del primer intento está en legacy/ tal como se auditó (etiqueta v1-original). No se ejecuta ni se modifica; se conserva para comparar. Su dataset de partida (Balanced_AHR.csv, del corpus Andalusian Hotels' Reviews, CC BY-NC 4.0) está en data/raw/.
| Done (guardrails, prompt versions); a smaller 9B generator was rejected (report) |
phase9_latencia.md| Classifier (mRoBERTa, 3 seeds) | Macro F1 0.781 on 11,414 unseen reviews (AHR); error 3.1 % on the 74 % it answers with confidence ≥ 0.85 | phase3b_realista.md |
| 9. Demo y refuerzo | Demo en vivo con personas reales, guardarraíles nuevos a partir de lo que escribieron, detección de trampas, latencia (de 75 a 49 s por reseña), juez asíncrono, modo repetición, regresión de prompts y comparación humana a ciegas | Hecho (guardarraíles, versiones del prompt); se descartó un generador más pequeño, de 9B (informe) |
| Clasificador (mRoBERTa, 3 semillas) | F1 macro 0,781 en 11.414 reseñas no vistas (AHR); error del 3,1 % en el 74 % que responde con confianza ≥ 0,85 | phase3b_realista.md |