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

Веб-интерфейс вопроса и источников

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

Состояние lesson-13 добавляет web.py и команду python3 app.py serve. Сервер привязывается только к 127.0.0.1:5200. При самостоятельной работе открывайте соответствующий локальный адрес в браузере. Ни сервер, ни страница здесь не запускались. Этот урок описывает ожидаемое представление и исходник, а не показывает выдуманный снимок интерфейса. Для доступа с другого компьютера потребуется отдельная настройка окружения и безопасности, которая не является частью текущей лаборатории.

Представление вопроса

Форма использует GET и параметр q. Это естественно для небольшого поиска без изменения состояния: адрес страницы можно скопировать с тем же вопросом. Однако строка попадёт в адресную строку, историю браузера и потенциально в журнал сервера. Вопрос может содержать личные сведения, поэтому удобство GET имеет цену. В нашем локальном обработчике обычное логирование request path отключено. Для публичного сервиса полезно рассмотреть POST, политику хранения вопросов и способ очистки истории; сам метод не заменяет согласованное обращение с данными.

Пустая форма показывает лишь поле и кнопку. Запрос отправляется в ask только после удаления внешних пробелов и проверки, что вопрос не пуст. Атрибут maxlength улучшает поведение браузера, но сервер повторяет предел пятисот символов. Посетитель может отправить запрос без формы, поэтому HTML-ограничение не является доверенной проверкой. Такой принцип применим и к роли: публичный обработчик всегда вызывает answer(question, "reader"), даже если в адресе кто-то добавил произвольный параметр role.

Обработчик разрешает только корневой путь /. Остальные получают 404, чтобы случайный запрос не превращался в чтение файлов с диска. Корпус, база и исходники не публикуются общим файловым сервером. Человек открывает только подготовленную страницу и заданные источники. Это небольшая, но полезная граница: быстрый прототип интерфейса не должен автоматически отдавать служебную заметку по пути /corpus/editor-notes.md. Контроль retrieval бессмысленен, если рядом существует обходной способ скачать весь каталог.

Экранирование каждого значения

Вопрос, answer, status, название и quote проходят html.escape. Для атрибутов ссылок и поля ввода включена обработка кавычек. Источник может содержать угловые скобки или строку, похожую на HTML; она должна показываться как текст, а не становиться элементом страницы. Ответ модели также не вставляется как готовый HTML. Это принципиальная граница: ни корпус, ни генерация не получают право выполнять JavaScript в браузере посетителя только потому, что их строка выглядит удобной для отображения.

URL источника уже проверен загрузчиком на точный HTTPS-узел. Экранирование всё равно нужно для корректного атрибута HTML. Проверка допустимого адреса и экранирование решают разные задачи: первая ограничивает назначение перехода, вторая предотвращает нарушение структуры разметки. Например, кавычка в необработанной строке могла бы закрыть href и добавить новый атрибут. Даже доверенные метаданные лучше выводить через один безопасный путь, чтобы будущий импорт не внёс исключение в случайную ветку интерфейса.

Политика Content-Security-Policy запрещает внешние ресурсы и скрипты. Небольшой inline-style нужен только для переноса длинного текста в pre. Форма может отправляться на тот же источник страницы. Это дополнительная граница браузера, не замена правильному экранированию. Политика также не делает сервер устойчивым к любому объёму запросов. Мы привязали лабораторию к loopback и ограничили вопрос, но не построили публичный API со всеми необходимыми лимитами и контролем параллельных вычислений.

Ответ и источники в интерфейсе

Страница выводит текстовый status рядом с ответом. Supported, insufficient и generated_unverified имеют разные значения, разобранные прежде. В готовом продукте их лучше переводить в понятные подписи: «Цитаты из документов», «Недостаточно сведений», «Объяснение локальной модели требует проверки». Учебный интерфейс сохраняет исходные имена, чтобы читатель мог сопоставить HTML с JSON-контрактом. Не стоит скрывать различие одной универсальной зелёной отметкой успеха: структурно успешная генерация ещё не является проверкой всех утверждений.

Источники показываются списком ссылок и blockquote. У извлекающего режима quote — выбранное предложение; у модельного — предоставленный ей фрагмент. Такое отображение делает происхождение видимым и позволяет проверить, не потеряла ли генерация важное условие. Но интерфейс пока не подсвечивает соответствие каждого предложения answer конкретному источнику. Список всех sources ниже длинного текста является лишь основанием для ручного сравнения, а не доказательством, что каждое утверждение поддержано всеми приведёнными ссылками.

Заголовок страницы и label формы написаны по-русски, а lang задан ru. Наличие label важно для чтения средствами доступности и для понятной связи поля с вопросом. Нам не нужен тяжёлый frontend-фреймворк ради одной формы. Маленький серверный HTML позволяет сначала проверить контракт данных. Если позже понадобится динамическое состояние ожидания или история вопросов, их можно добавлять отдельно, сохраняя безопасное текстовое отображение и ту же серверную границу доступа.

Ограничения локальной страницы

HTTP-сервер стандартной библиотеки не задуман как готовая публичная инфраструктура этого проекта. ThreadingHTTPServer создаёт конкурентные обработчики, а общий кэш, который появится позднее, будет простым памятьным словарём. Локальный пример не включает авторизацию, защиту от распределённой нагрузки и управление долгими модельными запросами. Даже при loopback другие процессы или вкладки компьютера могут обратиться к адресу. Поэтому не помещайте в учебный корпус реальные секреты и не расширяйте прослушивание на весь сетевой интерфейс без самостоятельной работы.

Полный web.py ниже показывает фактическую разметку. Для собственного разбора отправьте вопрос про sitemap, затем строку с <script> в форме. Ожидается отображение введённых символов как текста, а не выполнение скрипта. Сценарий не запускался при подготовке. Затем запросите несуществующий путь: сервер должен вернуть 404. Эти варианты помогают убедиться именно в границе интерфейса, не подменяя её измерением качества retrieval. Следующий урок составит набор вопросов и отдельные показатели поиска, отказа и происхождения ответа.

Полный файл web.py из состояния lesson-13:

import html
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit

def serve(answer):
    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            url = urlsplit(self.path)
            if url.path != "/":
                self.send_error(404)
                return
            question = parse_qs(url.query).get("q", [""])[0]
            if len(question) > 500:
                self.send_error(400, "Question too long")
                return
            result = answer(question, "reader") if question.strip() else None
            body = '<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Документы ProfessorWeb</title><main><h1>Вопрос по документам</h1>'
            body += '<form><label>Вопрос <input name="q" maxlength="500" value="' + html.escape(question, quote=True) + '"></label><button>Найти основание</button></form>'
            if result:
                body += '<h2>Ответ</h2><p>' + html.escape(result["status"]) + '</p><pre style="white-space:pre-wrap">' + html.escape(result["answer"]) + '</pre><h2>Источники</h2><ul>'
                for source in result["sources"]:
                    body += '<li><a href="' + html.escape(source["url"], quote=True) + '">' + html.escape(source["title"]) + '</a><blockquote>' + html.escape(source["quote"]) + '</blockquote></li>'
                body += '</ul>'
            payload = (body + '</main></html>').encode()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(payload)))
            self.send_header("Cache-Control", "no-store")
            self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'")
            self.end_headers()
            self.wfile.write(payload)
        def log_message(self, format, *args):
            pass  # Do not log query strings with user questions.
    ThreadingHTTPServer(("127.0.0.1", 5200), Handler).serve_forever()

Оглавление серии · Исходники всех шагов · Учебные маршруты

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