Persist regenerated answer versions
This commit is contained in:
@@ -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 وموافقة، ثم الاختبارات/الأوامر المعزولة.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -91,9 +91,19 @@ class ApiRepository {
|
||||
messages:
|
||||
(data['messages'] as List<dynamic>).map((item) {
|
||||
final message = item as Map<String, dynamic>;
|
||||
final content = message['content'] as String;
|
||||
final fromUser = message['role'] == 'user';
|
||||
final versions =
|
||||
(message['versions'] as List<dynamic>? ?? const [])
|
||||
.cast<String>();
|
||||
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(),
|
||||
|
||||
+72
-4
@@ -120,14 +120,46 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
!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<void> selectAnswerVersion(int messageIndex, int versionIndex) async {
|
||||
if (state.isSending ||
|
||||
messageIndex < 0 ||
|
||||
messageIndex >= state.messages.length) {
|
||||
return;
|
||||
}
|
||||
final messages = List<ChatMessage>.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<void> editLastQuestion(String text) async {
|
||||
if (state.isSending || text.trim().isEmpty) return;
|
||||
final messages = state.messages;
|
||||
@@ -146,6 +178,7 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
String text,
|
||||
List<ChatMessage> history, {
|
||||
bool appendUserMessage = true,
|
||||
List<String> previousAnswers = const [],
|
||||
}) async {
|
||||
final prompt = text.trim();
|
||||
if (prompt.isEmpty || state.isSending || state.isLoadingHistory) return;
|
||||
@@ -173,7 +206,16 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
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<ChatState> {
|
||||
)) {
|
||||
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<ChatState> {
|
||||
? 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,
|
||||
|
||||
+21
-3
@@ -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<String> answerVersions;
|
||||
final int selectedVersion;
|
||||
|
||||
Map<String, dynamic> toJson() => {'text': text, 'fromUser': fromUser};
|
||||
Map<String, dynamic> toJson() => {
|
||||
'text': text,
|
||||
'fromUser': fromUser,
|
||||
'answerVersions': answerVersions,
|
||||
'selectedVersion': selectedVersion,
|
||||
};
|
||||
|
||||
factory ChatMessage.fromJson(Map<String, dynamic> 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<dynamic>? ?? const []).cast<String>(),
|
||||
selectedVersion: json['selectedVersion'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class SavedConversation {
|
||||
|
||||
@@ -710,6 +710,52 @@ class _ChatPageState extends State<ChatPage> {
|
||||
)
|
||||
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<ChatCubit>()
|
||||
.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<ChatCubit>()
|
||||
.selectAnswerVersion(
|
||||
index,
|
||||
message.selectedVersion + 1,
|
||||
),
|
||||
icon: const Icon(Icons.chevron_left_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!chat.isSending &&
|
||||
!showTyping &&
|
||||
index == chat.messages.length - 1 &&
|
||||
|
||||
@@ -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 = <List<String>>[
|
||||
['الجواب الأول'],
|
||||
['الجواب الثاني'],
|
||||
];
|
||||
SavedConversation? lastSaved;
|
||||
|
||||
@override
|
||||
Future<String> getModelName() async => 'gemma4:e2b';
|
||||
|
||||
@override
|
||||
Future<List<String>> getAvailableModels() async => ['gemma4:e2b'];
|
||||
|
||||
@override
|
||||
Future<List<SavedConversation>> listConversations() async => [];
|
||||
|
||||
@override
|
||||
Future<void> saveConversation(SavedConversation conversation) async {
|
||||
lastSaved = conversation;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<String> completeStream(
|
||||
List<ChatMessage> messages, {
|
||||
String? model,
|
||||
}) => Stream<String>.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<void>.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();
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user