Persist regenerated answer versions

This commit is contained in:
Hamza Ayed
2026-10-01 12:51:48 +03:00
parent e1f981d29e
commit 7a042d036c
9 changed files with 445 additions and 27 deletions
+4 -4
View File
@@ -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 وموافقة، ثم الاختبارات/الأوامر المعزولة.
+83 -13
View File
@@ -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:
+16 -1
View File
@@ -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(),
@@ -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,
@@ -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()