From 7a042d036c054615552e6ce4f022b1c7a49b6a26 Mon Sep 17 00:00:00 2001 From: Hamza Ayed Date: Thu, 1 Oct 2026 12:51:48 +0300 Subject: [PATCH] Persist regenerated answer versions --- SovereignAI-Starter/ROADMAP.md | 8 +- SovereignAI-Starter/app/database.py | 96 ++++++++++++-- SovereignAI-Starter/app/main.py | 17 ++- .../lib/core/network/api_repository.dart | 21 ++- .../chat/presentation/cubit/chat_cubit.dart | 76 ++++++++++- .../chat/presentation/cubit/chat_state.dart | 24 +++- .../chat/presentation/pages/chat_page.dart | 46 +++++++ .../flutter_app/test/chat_cubit_test.dart | 59 +++++++++ .../tests/test_database_versions.py | 125 ++++++++++++++++++ 9 files changed, 445 insertions(+), 27 deletions(-) create mode 100644 SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart create mode 100644 SovereignAI-Starter/tests/test_database_versions.py diff --git a/SovereignAI-Starter/ROADMAP.md b/SovereignAI-Starter/ROADMAP.md index 928e20b..b90e2eb 100644 --- a/SovereignAI-Starter/ROADMAP.md +++ b/SovereignAI-Starter/ROADMAP.md @@ -13,7 +13,7 @@ - [x] إنشاء أصل أيقونة موحّد وإعداد توليد أيقونات Android/iOS/macOS/Windows/Web، وإضافة أصل تغليف Linux. - [x] اختيار نموذج Ollama مثبت من واجهة التطبيق، وتمريره صراحةً للطلب. - [x] وضع وكيل تجريبي للبحث وقراءة مقتطفات ملفات المشروع فقط، مع إرجاع مراجع الملفات. -- [ ] حفظ محاولات الإجابة كنسخ منفصلة بدل استبدالها، مع واجهة للتنقل بينها. +- [x] حفظ محاولات الإجابة كنسخ منفصلة بدل استبدالها، مع واجهة للتنقل بينها. (2026-10-01: اجتازت اختبارات Flutter التنقل والحفظ، واختبارات SQLite/API الترحيل والحفظ والاسترجاع.) - [ ] إظهار حالة النموذج والوقت وسبب الخطأ، وتوفير إيقاف التوليد. - [ ] دعم إخراج Markdown كامل، وروابط قابلة للفتح، ونسخ الكتل البرمجية منفردة. @@ -90,7 +90,7 @@ ## إضافات قبل توسيع الوكيل - إضافة إلغاء التوليد وإظهار المدة وحالة الاتصال بكل وضوح. -- حفظ نسخ إعادة التوليد والتنقل بينها، مع تجربة أفضل للأخطاء وإعادة المحاولة. +- [x] حفظ نسخ إعادة التوليد والتنقل بينها (2026-10-01: اختبار واجهة وAPI وقاعدة البيانات ناجح)؛ تجربة أفضل للأخطاء وإعادة المحاولة ما زالت لاحقة. - إكمال طبقة مزوّد النموذج وعقد API قبل ربط أدوات الوكيل. - حفظ إعدادات التنبيه والمظهر وعنوان API بطريقة محلية آمنة. - إضافة دعم Markdown موثوقًا، وروابط المصادر، ونسخ كتل الكود بصورة مستقلة. @@ -99,8 +99,8 @@ ## ترتيب التنفيذ القادم -1. التحقق من إجراءات المحادثة الجديدة على Windows Debug. -2. إيقاف التوليد وحفظ نسخ الإجابات وترحيل SQLite. +1. [x] التحقق من إجراءات المحادثة الجديدة على Windows Debug (2026-10-01): تشغيل API وGemma وGroq، اختبار صوت من الميكروفون حتى التفريغ والرد والحفظ في SQLite، نجاح اختبار الواجهة على نافذة 800px ونجاح `flutter analyze`. +2. [x] حفظ نسخ الإجابات وترحيل SQLite (2026-10-01): ترحيل قاعدة قديمة مع الحفاظ على الرسائل، وحفظ النسخ واسترجاع النسخة المختارة عبر PUT/GET؛ اجتاز اختبارا Python واختبار API حي على قاعدة التطبيق ثم حذف سجل الاختبار. اختبارا Flutter نجحا، و`flutter analyze` بلا ملاحظات. شُغّلت نسخة Windows Debug باسم Mithqal AI واتصلت الخدمة بـGemma؛ اختبار API للمحادثة أعاد ردًا عربيًا. الإلغاء والمدة وحالة النموذج ما زالت مفتوحة ضمن الخطوة التالية. 3. إضافة طبقة مزود النموذج واكتشاف النماذج. 4. تصميم عقد الأدوات وسجل التنفيذ، ثم أداة قراءة الملفات داخل مساحة يحددها المستخدم. 5. إضافة التعديل مع diff وموافقة، ثم الاختبارات/الأوامر المعزولة. diff --git a/SovereignAI-Starter/app/database.py b/SovereignAI-Starter/app/database.py index b99fdf2..a8bbbfd 100644 --- a/SovereignAI-Starter/app/database.py +++ b/SovereignAI-Starter/app/database.py @@ -6,7 +6,8 @@ import os import sqlite3 from uuid import UUID from pathlib import Path -from typing import Any +from contextlib import contextmanager +from typing import Any, Iterator def _database_path() -> Path: @@ -27,11 +28,16 @@ LOCAL_USER_ID = str( ) -def _connect() -> sqlite3.Connection: +@contextmanager +def _connect() -> Iterator[sqlite3.Connection]: connection = sqlite3.connect(DATABASE_PATH, timeout=10) connection.row_factory = sqlite3.Row connection.execute("PRAGMA foreign_keys = ON") - return connection + try: + with connection: + yield connection + finally: + connection.close() def initialize_database() -> None: @@ -70,13 +76,28 @@ def initialize_database() -> None: conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK(role IN ('user', 'assistant')), content TEXT NOT NULL, + selected_version INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, id); + + CREATE TABLE IF NOT EXISTS message_versions ( + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + version_index INTEGER NOT NULL CHECK(version_index >= 0), + content TEXT NOT NULL, + PRIMARY KEY(message_id, version_index) + ); """ ) + message_columns = { + row["name"] for row in connection.execute("PRAGMA table_info(messages)") + } + if "selected_version" not in message_columns: + connection.execute( + "ALTER TABLE messages ADD COLUMN selected_version INTEGER NOT NULL DEFAULT 0" + ) def ensure_user(user_id: str) -> None: @@ -113,18 +134,47 @@ def get_conversation(user_id: str, conversation_id: str) -> dict[str, Any] | Non if conversation is None: return None messages = connection.execute( - "SELECT role, content, created_at FROM messages " + "SELECT id, role, content, selected_version, created_at FROM messages " "WHERE conversation_id = ? ORDER BY id", (conversation_id,), ).fetchall() - return {**dict(conversation), "messages": [dict(row) for row in messages]} + versions = connection.execute( + """ + SELECT mv.message_id, mv.content + FROM message_versions AS mv + JOIN messages AS m ON m.id = mv.message_id + WHERE m.conversation_id = ? + ORDER BY mv.message_id, mv.version_index + """, + (conversation_id,), + ).fetchall() + versions_by_message: dict[int, list[str]] = {} + for version in versions: + versions_by_message.setdefault(version["message_id"], []).append( + version["content"] + ) + saved_messages = [] + for message in messages: + message_versions = versions_by_message.get(message["id"], []) + if message["role"] == "assistant" and not message_versions: + message_versions = [message["content"]] + saved_messages.append( + { + "role": message["role"], + "content": message["content"], + "selected_version": message["selected_version"], + "versions": message_versions, + "created_at": message["created_at"], + } + ) + return {**dict(conversation), "messages": saved_messages} def save_conversation( user_id: str, conversation_id: str, title: str, - messages: list[dict[str, str]], + messages: list[dict[str, Any]], timestamp: str, ) -> None: with _connect() as connection: @@ -145,13 +195,33 @@ def save_conversation( (conversation_id, user_id, title, timestamp, timestamp), ) connection.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,)) - connection.executemany( - "INSERT INTO messages(conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", - [ - (conversation_id, message["role"], message["content"], timestamp) - for message in messages - ], - ) + for message in messages: + cursor = connection.execute( + """ + INSERT INTO messages( + conversation_id, role, content, selected_version, created_at + ) VALUES (?, ?, ?, ?, ?) + """, + ( + conversation_id, + message["role"], + message["content"], + message.get("selected_version", 0), + timestamp, + ), + ) + if message["role"] == "assistant": + versions = message.get("versions") or [message["content"]] + connection.executemany( + """ + INSERT INTO message_versions(message_id, version_index, content) + VALUES (?, ?, ?) + """, + [ + (cursor.lastrowid, index, content) + for index, content in enumerate(versions) + ], + ) def delete_conversation(user_id: str, conversation_id: str) -> bool: diff --git a/SovereignAI-Starter/app/main.py b/SovereignAI-Starter/app/main.py index 621e981..3bdb5e6 100644 --- a/SovereignAI-Starter/app/main.py +++ b/SovereignAI-Starter/app/main.py @@ -14,7 +14,7 @@ import httpx from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from app import database from app import workspace @@ -69,6 +69,21 @@ class WorkspaceAgentRequest(AgentRequest): class StoredMessage(BaseModel): role: str = Field(pattern="^(user|assistant)$") content: str + versions: list[str] = Field(default_factory=list, max_length=32) + selected_version: int = Field(default=0, ge=0) + + @model_validator(mode="after") + def validate_answer_versions(self) -> "StoredMessage": + if self.role == "user" and self.versions: + raise ValueError("User messages cannot contain assistant answer versions.") + if self.versions: + if self.selected_version >= len(self.versions): + raise ValueError("selected_version is outside the versions list.") + if self.versions[self.selected_version] != self.content: + raise ValueError("content must match the selected answer version.") + elif self.selected_version != 0: + raise ValueError("selected_version must be zero when versions are omitted.") + return self class ConversationWrite(BaseModel): diff --git a/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart b/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart index 51fc670..42fcea4 100644 --- a/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart +++ b/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart @@ -91,9 +91,19 @@ class ApiRepository { messages: (data['messages'] as List).map((item) { final message = item as Map; + final content = message['content'] as String; + final fromUser = message['role'] == 'user'; + final versions = + (message['versions'] as List? ?? const []) + .cast(); return ChatMessage( - message['content'] as String, - fromUser: message['role'] == 'user', + content, + fromUser: fromUser, + answerVersions: + fromUser + ? const [] + : (versions.isEmpty ? [content] : versions), + selectedVersion: message['selected_version'] as int? ?? 0, ); }).toList(), ); @@ -111,6 +121,13 @@ class ApiRepository { (message) => { 'role': message.fromUser ? 'user' : 'assistant', 'content': message.text, + if (!message.fromUser) ...{ + 'versions': + message.answerVersions.isEmpty + ? [message.text] + : message.answerVersions, + 'selected_version': message.selectedVersion, + }, }, ) .toList(), diff --git a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart index 085dbd0..76d4c09 100644 --- a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart +++ b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart @@ -120,14 +120,46 @@ class ChatCubit extends Cubit { !messages[messages.length - 2].fromUser) { return; } + final previousAnswer = messages.last; + final versions = + previousAnswer.answerVersions.isEmpty + ? [previousAnswer.text] + : previousAnswer.answerVersions; final history = messages.sublist(0, messages.length - 1); await _sendWithHistory( history.last.text, history, appendUserMessage: false, + previousAnswers: versions, ); } + Future selectAnswerVersion(int messageIndex, int versionIndex) async { + if (state.isSending || + messageIndex < 0 || + messageIndex >= state.messages.length) { + return; + } + final messages = List.from(state.messages); + final message = messages[messageIndex]; + final versions = message.answerVersions; + if (message.fromUser || versionIndex < 0 || versionIndex >= versions.length) { + return; + } + messages[messageIndex] = ChatMessage( + versions[versionIndex], + fromUser: false, + answerVersions: versions, + selectedVersion: versionIndex, + ); + try { + await _saveCurrent(messages); + emit(state.copyWith(clearError: true)); + } catch (error) { + emit(state.copyWith(error: 'تعذر حفظ نسخة الإجابة المختارة: $error')); + } + } + Future editLastQuestion(String text) async { if (state.isSending || text.trim().isEmpty) return; final messages = state.messages; @@ -146,6 +178,7 @@ class ChatCubit extends Cubit { String text, List history, { bool appendUserMessage = true, + List previousAnswers = const [], }) async { final prompt = text.trim(); if (prompt.isEmpty || state.isSending || state.isLoadingHistory) return; @@ -173,7 +206,16 @@ class ChatCubit extends Cubit { if (answer.trim().isEmpty) { throw Exception('لم يصل نص في إجابة الوكيل.'); } - final updated = [...messages, ChatMessage(answer, fromUser: false)]; + final answerVersions = [...previousAnswers, answer]; + final updated = [ + ...messages, + ChatMessage( + answer, + fromUser: false, + answerVersions: answerVersions, + selectedVersion: answerVersions.length - 1, + ), + ]; emit(state.copyWith(messages: updated, isSending: false)); await _saveCurrent(updated); return; @@ -184,15 +226,33 @@ class ChatCubit extends Cubit { )) { if (isClosed) return; answer += delta; + final answerVersions = [...previousAnswers, answer]; emit( state.copyWith( - messages: [...messages, ChatMessage(answer, fromUser: false)], + messages: [ + ...messages, + ChatMessage( + answer, + fromUser: false, + answerVersions: answerVersions, + selectedVersion: answerVersions.length - 1, + ), + ], ), ); } if (isClosed) return; if (answer.trim().isEmpty) throw Exception('لم يصل نص في الإجابة.'); - final updated = [...messages, ChatMessage(answer, fromUser: false)]; + final answerVersions = [...previousAnswers, answer]; + final updated = [ + ...messages, + ChatMessage( + answer, + fromUser: false, + answerVersions: answerVersions, + selectedVersion: answerVersions.length - 1, + ), + ]; emit(state.copyWith(messages: updated, isSending: false)); await _saveCurrent(updated); } catch (error) { @@ -202,7 +262,15 @@ class ChatCubit extends Cubit { ? originalMessages : answer.isEmpty ? messages - : [...messages, ChatMessage(answer, fromUser: false)]; + : [ + ...messages, + ChatMessage( + answer, + fromUser: false, + answerVersions: [...previousAnswers, answer], + selectedVersion: previousAnswers.length, + ), + ]; emit( state.copyWith( messages: partial, diff --git a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_state.dart b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_state.dart index ca0f810..6531e81 100644 --- a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_state.dart +++ b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_state.dart @@ -1,13 +1,31 @@ class ChatMessage { - const ChatMessage(this.text, {required this.fromUser}); + const ChatMessage( + this.text, { + required this.fromUser, + this.answerVersions = const [], + this.selectedVersion = 0, + }); final String text; final bool fromUser; + final List answerVersions; + final int selectedVersion; - Map toJson() => {'text': text, 'fromUser': fromUser}; + Map toJson() => { + 'text': text, + 'fromUser': fromUser, + 'answerVersions': answerVersions, + 'selectedVersion': selectedVersion, + }; factory ChatMessage.fromJson(Map json) => - ChatMessage(json['text'] as String, fromUser: json['fromUser'] as bool); + ChatMessage( + json['text'] as String, + fromUser: json['fromUser'] as bool, + answerVersions: + (json['answerVersions'] as List? ?? const []).cast(), + selectedVersion: json['selectedVersion'] as int? ?? 0, + ); } class SavedConversation { diff --git a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart index 777db7f..8dbbf4f 100644 --- a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart @@ -710,6 +710,52 @@ class _ChatPageState extends State { ) else _AssistantMarkdown(text: message.text), + if (!message.fromUser && message.answerVersions.length > 1) + Align( + alignment: AlignmentDirectional.centerStart, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'نسخة الإجابة السابقة', + visualDensity: VisualDensity.compact, + onPressed: + chat.isSending || message.selectedVersion == 0 + ? null + : () => context + .read() + .selectAnswerVersion( + index, + message.selectedVersion - 1, + ), + icon: const Icon(Icons.chevron_right_rounded), + ), + Text( + 'النسخة ${message.selectedVersion + 1} من ${message.answerVersions.length}', + style: const TextStyle( + color: Color(0xFF718078), + fontSize: 11, + ), + ), + IconButton( + tooltip: 'نسخة الإجابة التالية', + visualDensity: VisualDensity.compact, + onPressed: + chat.isSending || + message.selectedVersion >= + message.answerVersions.length - 1 + ? null + : () => context + .read() + .selectAnswerVersion( + index, + message.selectedVersion + 1, + ), + icon: const Icon(Icons.chevron_left_rounded), + ), + ], + ), + ), if (!chat.isSending && !showTyping && index == chat.messages.length - 1 && diff --git a/SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart b/SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart new file mode 100644 index 0000000..fe6e49a --- /dev/null +++ b/SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flutter_app/core/network/api_repository.dart'; +import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart'; +import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart'; + +class _VersionTestApi extends ApiRepository { + _VersionTestApi() : super(baseUrl: 'http://127.0.0.1:8000'); + + final responses = >[ + ['الجواب الأول'], + ['الجواب الثاني'], + ]; + SavedConversation? lastSaved; + + @override + Future getModelName() async => 'gemma4:e2b'; + + @override + Future> getAvailableModels() async => ['gemma4:e2b']; + + @override + Future> listConversations() async => []; + + @override + Future saveConversation(SavedConversation conversation) async { + lastSaved = conversation; + } + + @override + Stream completeStream( + List messages, { + String? model, + }) => Stream.fromIterable(responses.removeAt(0)); +} + +void main() { + test('regeneration keeps prior answers and selection is persisted', () async { + final api = _VersionTestApi(); + final cubit = ChatCubit(api); + await Future.delayed(Duration.zero); + + await cubit.send('ما معنى API؟'); + expect(cubit.state.messages.last.answerVersions, ['الجواب الأول']); + + await cubit.regenerateLastAnswer(); + expect(cubit.state.messages.last.text, 'الجواب الثاني'); + expect(cubit.state.messages.last.answerVersions, ['الجواب الأول', 'الجواب الثاني']); + expect(cubit.state.messages.last.selectedVersion, 1); + + await cubit.selectAnswerVersion(1, 0); + expect(cubit.state.messages.last.text, 'الجواب الأول'); + expect(cubit.state.messages.last.selectedVersion, 0); + expect(api.lastSaved?.messages.last.answerVersions, ['الجواب الأول', 'الجواب الثاني']); + expect(api.lastSaved?.messages.last.selectedVersion, 0); + + await cubit.close(); + }); +} diff --git a/SovereignAI-Starter/tests/test_database_versions.py b/SovereignAI-Starter/tests/test_database_versions.py new file mode 100644 index 0000000..b13b1ad --- /dev/null +++ b/SovereignAI-Starter/tests/test_database_versions.py @@ -0,0 +1,125 @@ +import sqlite3 +import unittest +from pathlib import Path +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from app import database +from app.main import app + + +class ConversationVersionMigrationTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = Path(__file__).parent / f".tmp-db-{uuid4().hex}" + self.temp_dir.mkdir() + self.database_path = self.temp_dir / "legacy.sqlite3" + self.original_database_path = database.DATABASE_PATH + database.DATABASE_PATH = self.database_path + connection = sqlite3.connect(self.database_path) + try: + connection.executescript( + """ + CREATE TABLE users ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE conversations ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK(role IN ('user', 'assistant')), + content TEXT NOT NULL, + created_at TEXT NOT NULL + ); + INSERT INTO users(id) VALUES ('00000000-0000-4000-8000-000000000001'); + INSERT INTO conversations(id, user_id, title, created_at, updated_at) + VALUES ('conversation-1', '00000000-0000-4000-8000-000000000001', + 'قديم', '2026-01-01', '2026-01-01'); + INSERT INTO messages(conversation_id, role, content, created_at) + VALUES ('conversation-1', 'assistant', 'جواب قديم', '2026-01-01'); + """ + ) + finally: + connection.close() + database.initialize_database() + + def tearDown(self) -> None: + database.DATABASE_PATH = self.original_database_path + for path in self.temp_dir.iterdir(): + path.unlink() + self.temp_dir.rmdir() + + def test_old_history_migrates_and_answer_versions_round_trip(self) -> None: + database.initialize_database() + old_conversation = database.get_conversation( + "00000000-0000-4000-8000-000000000001", "conversation-1" + ) + self.assertIsNotNone(old_conversation) + self.assertEqual(old_conversation["messages"][0]["versions"], ["جواب قديم"]) + self.assertEqual(old_conversation["messages"][0]["selected_version"], 0) + + database.save_conversation( + "00000000-0000-4000-8000-000000000001", + "conversation-1", + "تجربة النسخ", + [ + {"role": "user", "content": "السؤال"}, + { + "role": "assistant", + "content": "الجواب الأول", + "versions": ["الجواب الأول", "الجواب الثاني"], + "selected_version": 0, + }, + ], + "2026-01-02T00:00:00+00:00", + ) + conversation = database.get_conversation( + "00000000-0000-4000-8000-000000000001", "conversation-1" + ) + self.assertEqual( + conversation["messages"][1]["versions"], ["الجواب الأول", "الجواب الثاني"] + ) + self.assertEqual(conversation["messages"][1]["selected_version"], 0) + + def test_api_saves_and_returns_selected_answer_version(self) -> None: + user_id = "00000000-0000-4000-8000-000000000001" + conversation_id = "00000000-0000-4000-8000-000000000099" + payload = { + "title": "API version test", + "messages": [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": "second answer", + "versions": ["first answer", "second answer"], + "selected_version": 1, + }, + ], + } + with TestClient(app) as client: + saved = client.put( + f"/v1/conversations/{conversation_id}", + headers={"X-User-ID": user_id}, + json=payload, + ) + self.assertEqual(saved.status_code, 200, saved.text) + loaded = client.get( + f"/v1/conversations/{conversation_id}", + headers={"X-User-ID": user_id}, + ) + self.assertEqual(loaded.status_code, 200, loaded.text) + assistant = loaded.json()["messages"][1] + self.assertEqual(assistant["content"], "second answer") + self.assertEqual(assistant["versions"], ["first answer", "second answer"]) + self.assertEqual(assistant["selected_version"], 1) + + +if __name__ == "__main__": + unittest.main()