Перейти к содержанию

Локальный веб-интерфейс и передача проекта

В предыдущих уроках мы построили путь от собственного корпуса до разрешённых источников и необязательного ответа модели. Завершим лабораторию небольшой веб-страницей. Читатель вводит вопрос, сервер обращается к подготовленной базе, а интерфейс показывает структурированный результат. Отдельные страницы позволяют открыть найденный фрагмент в учебном корпусе.

Состояние lesson-12 добавляет web.py к полному набору предыдущих файлов. Сервер слушает только 127.0.0.1:8765. Автором приложение, база и модели не запускались. Это локальный демонстрационный сервис; работающие ссылки на сами уроки ProfessorWeb не являются доказательством выполнения приложенного приложения.

Что браузер передаёт серверу

Форма отправляет объект с единственным полем q. Клиент не выбирает роль, пространство векторов, адрес базы или имя модели. Эти параметры относятся к конфигурации процесса. Если разрешить браузеру произвольно менять их, пользовательский вопрос станет способом вмешаться в устройство поиска.

На сервере проверяем тип содержимого и ограничиваем размер тела запроса. Затем принимаем только ожидаемый набор полей. Проверка поискового вопроса остаётся в функции поиска: строка должна быть непустой и иметь допустимую длину. Ограничение maxlength в HTML помогает интерфейсу, но не заменяет серверные условия.

Число байтов тела и число символов вопроса отличаются. Русский текст занимает несколько байтов на символ в UTF-8, а JSON добавляет имена полей и служебные знаки. Поэтому ограничения решают разные задачи: первое сдерживает объём запроса, второе определяет предмет работы поиска.

import html,json,os
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from urllib.parse import urlsplit
from chunks import load_corpus,split_doc
from retrieve import allowed_role
from answer import answer
ROLE=allowed_role(os.environ.get('DEMO_ROLE','reader')) # server-controlled; not browser identity
class Handler(BaseHTTPRequestHandler):
    def send(self,status,body,kind='application/json'):
        payload=body.encode()
        self.send_response(status);self.send_header('Content-Type',kind+'; charset=utf-8')
        self.send_header('Cache-Control','no-store');self.send_header('Content-Length',str(len(payload)))
        self.send_header('X-Content-Type-Options','nosniff');self.end_headers();self.wfile.write(payload)
    def do_GET(self):
        path=urlsplit(self.path).path
        if path=='/':
            self.send(200,'<!doctype html><html lang="ru"><meta charset="utf-8"><title>Полка RAG</title><h1>Поиск по библиотеке</h1><form id="ask"><label>Вопрос <input name="q" maxlength="1000" required></label><button>Найти</button></form><pre id="out" aria-live="polite"></pre><script>document.querySelector("form").onsubmit=async(e)=>{e.preventDefault();let q=new FormData(e.target).get("q");let out=document.querySelector("#out");out.textContent="Загрузка";try{let r=await fetch("/api/ask",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({q})});out.textContent=JSON.stringify(await r.json(),null,2)}catch(err){out.textContent="Сервис недоступен"}};</script></html>','text/html');return
        for doc in load_corpus():
            if path==doc['url'] and (doc['visibility']=='public' or ROLE=='editor'):
                body=''.join(f'<section id="part-{p["part"]:02}"><h2>{html.escape(p["heading"])}</h2><p>{html.escape(p["text"])}</p></section>' for p in split_doc(doc))
                self.send(200,'<!doctype html><html lang="ru"><meta charset="utf-8"><title>'+html.escape(doc['title'])+'</title><h1>'+html.escape(doc['title'])+'</h1>'+body+'</html>','text/html');return
        self.send(404,json.dumps({'error':'not_found'}))
    def do_POST(self):
        if urlsplit(self.path).path!='/api/ask':self.send(404,'{"error":"not_found"}');return
        if self.headers.get('Content-Type','').split(';')[0]!='application/json':self.send(415,'{"error":"content_type"}');return
        try:
            size=int(self.headers.get('Content-Length','0'))
            if not 1<=size<=5000:raise ValueError('invalid body size')
            body=json.loads(self.rfile.read(size));q=body['q']
            if set(body)!={'q'}:raise ValueError('unexpected input')
            if not isinstance(q,str) or not q.strip() or len(q)>1000:raise ValueError('invalid query')
        except (ValueError,KeyError,TypeError):self.send(400,'{"error":"invalid_request"}');return
        try:
            result=answer(q,ROLE)
            self.send(200,json.dumps(result,ensure_ascii=False))
        except Exception:self.send(503,'{"error":"source_service_unavailable"}')
    def log_message(self,*args):pass # no questions or document contents in default access log
if __name__=='__main__':ThreadingHTTPServer(('127.0.0.1',8765),Handler).serve_forever()

Роль принадлежит процессу

Переменная DEMO_ROLE задаёт reader или editor для всего запущенного сервера. По умолчанию используется reader. Это выбранная конфигурация локального оператора, а не вход пользователя. Страница не имеет формы авторизации и не отличает нескольких людей, которые обращаются к одному процессу.

Ожидаемое поведение режима читателя — отсутствие служебного документа в поиске и отказ открыть его страницу. Режим редактора допускает его по договору лаборатории. Нельзя выставить такой процесс наружу и считать значение переменной полноценной системой прав. Для публичного сервиса понадобятся доверенная идентификация, проверка доступа к каждому запросу и отдельные права базы.

Адрес 127.0.0.1 ограничивает интерфейс сетевого прослушивания, но не делает содержимое автоматически безопасным. Другие процессы на компьютере могут обращаться к порту, а инфраструктура может создавать туннели. Доступность сервиса и доверие к клиенту нужно рассматривать отдельно, прежде чем менять адрес на внешний.

Показываем текст без выполнения разметки

Результат запроса записывается в textContent элемента pre. JSON остаётся текстом и не становится HTML, даже если модель вернула строку с тегом. На страницах источников заголовки и абзацы проходят через html.escape. Эти решения отделяют содержание библиотеки от исполняемой разметки.

Они не проверяют истинность ответа и не решают все вопросы безопасности браузерного приложения. Здесь нет полноценного оформления ссылок, защищённых пользовательских сессий или политики для публикации наружу. Предмет урока — граница между текстом результата и HTML, а не обещание готового коммерческого сервиса.

Если позже превратите JSON в карточки, создавайте элементы и назначайте текст явно. Адрес ссылки берите из доверенного договора источников. Не вставляйте всю строку ответа через innerHTML, чтобы получить красивое форматирование. Такое изменение добавит новую поверхность обработки недоверенного текста.

Страница источника и ревизия базы

Поиск использует активные фрагменты PostgreSQL, а страницы /documents/... читаются из corpus.json. Поэтому эти два представления не являются общим атомарным снимком. После изменения файла нужно закончить импорт соответствующей ревизии и перезапустить демонстрационный сервер до дальнейшего использования.

Иначе читатель может получить цитату старой ревизии, а по ссылке увидеть новый абзац с тем же номером части. Для настоящей библиотеки следует отдавать источник по идентификатору ревизии либо хранить неизменяемое представление вместе с индексом. Простая постоянная ссылка удобна, но сама по себе не гарантирует совпадение содержания.

Операция tombstone тоже относится только к выдаче базы. Она не изменяет corpus.json, а обработчик страницы источника самостоятельно не проверяет поле deleted. Следовательно, скрытый из поиска публичный документ может оставаться доступным по прямому учебному адресу. Это не глобальное снятие веб-публикации. Если материал нужно закрыть целиком, переход доступности должен охватить обе системы; для приложения с настоящими пользователями разумнее согласовать источник страницы с той же политикой базы. Перезапуск сервера сам по себе не скрывает текст, который по-прежнему находится в файле.

Наши адреса /documents/cache.html и /documents/urls.html относятся к учебному приложению. Они не заменяют исторические адреса ProfessorWeb. При подключении настоящей библиотеки сохраняйте её постоянные URL, фиксируйте ревизию цитаты и продумывайте поведение после скрытия документа. Не переносите служебный адрес примера в sitemap рабочего сайта.

Ошибка и отсутствие доказательств

Некорректное тело запроса приводит к клиентской ошибке, неизвестный маршрут — к отсутствию страницы. Недоступность базы или модели возвращает отдельное состояние сервиса. В ответ не включаем пароль подключения, подробный traceback или текст скрытого источника. Такие сведения не нужны браузеру для понимания результата.

Статусы no_sources, sources_only, insufficient и generated_unverified относятся к успешно обработанному поиску. Они не должны превращаться в сообщение «всё сломалось» только потому, что система отказалась сделать утверждение. Хороший интерфейс показывает различие между недостатком доказательств и технической недоступностью.

В демонстрации журнал доступа не сохраняет вопросы. Это сокращает один канал накопления пользовательского текста, но не описывает все журналы окружения. Перед настоящим размещением необходимо отдельно решить, какие данные сохранять, где они доступны оператору и как долго живут. Не называйте отсутствие одной записи гарантией отсутствия данных везде.

Передача проекта и дальнейшая работа

Передавайте исходный корпус, планы уроков, точные версии зависимостей и описание переменных среды. Строку подключения к базе храните вне репозитория. Digest моделей записывайте как характеристику артефакта, а не как секрет. Следующий разработчик должен понимать, какой файл задаёт текст, какой процесс импортирует его и какие таблицы являются производными.

При первом самостоятельном запуске начните с режима цитат и маленького собственного корпуса. Сначала проверьте глазами связь вопроса, разрешённых фрагментов и адресов, затем подключайте генератор. Это предложенная последовательность для читателя; выполненных запусков в авторских материалах нет.

К дальнейшим задачам относятся миграции схемы, атомарная смена всего корпуса, настоящий вход, ограничение нагрузки и независимая оценка ответов. Теперь для каждой из них есть понятное место в цепочке. Лаборатория заканчивается цельным локальным примером и ясными ограничениями, на основе которых можно расширять поиск по учебной библиотеке.

Исходники и документация

Оглавление серии · Все состояния проекта. Для этого урока откройте папку lesson-12.

pgvector, транзакции Psycopg, Ollama Embed, структурированные ответы.

Примеры подготовлены как исходники. Их выполнение, замеры, тесты и публикация при написании не проводились. Описанные результаты ожидаемые.