feat(personal): add editable report outlines
This commit is contained in:
+28
@@ -4,6 +4,9 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.AskRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.AskResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.DownloadUrlResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreateRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemCreatedResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.OcrProgressResponse;
|
||||
@@ -18,6 +21,7 @@ import org.dromara.aihr.personal.domain.PersonalAssistantDto.UrlItemRequest;
|
||||
import org.dromara.aihr.personal.service.PersonalAnswerService;
|
||||
import org.dromara.aihr.personal.service.PersonalCleanupService;
|
||||
import org.dromara.aihr.personal.service.PersonalIngestionService;
|
||||
import org.dromara.aihr.personal.service.PersonalExportService;
|
||||
import org.dromara.aihr.personal.service.PersonalPdfOcrService;
|
||||
import org.dromara.aihr.personal.service.PersonalRetrievalService;
|
||||
import org.dromara.aihr.personal.service.PersonalSpaceService;
|
||||
@@ -31,6 +35,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -56,6 +61,7 @@ public class PersonalAssistantController {
|
||||
private final PersonalAnswerService answerService;
|
||||
private final PersonalCleanupService cleanupService;
|
||||
private final PersonalPdfOcrService pdfOcrService;
|
||||
private final PersonalExportService exportService;
|
||||
|
||||
@GetMapping("/space")
|
||||
public R<SpaceResponse> space() {
|
||||
@@ -151,6 +157,28 @@ public class PersonalAssistantController {
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/exports/outline")
|
||||
public R<ExportOutlineResponse> createOutline(@RequestBody ExportOutlineCreateRequest request) {
|
||||
return R.ok(exportService.createOutline(owner(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/exports/{id}")
|
||||
public R<ExportOutlineResponse> outline(@PathVariable long id) {
|
||||
return R.ok(exportService.outline(owner(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/exports/{id}/outline")
|
||||
public R<ExportOutlineResponse> updateOutline(@PathVariable long id,
|
||||
@RequestBody ExportOutlineUpdateRequest request) {
|
||||
return R.ok(exportService.updateOutline(owner(), id, request));
|
||||
}
|
||||
|
||||
@PostMapping("/exports/{id}/confirm")
|
||||
public R<Void> confirmOutline(@PathVariable long id) {
|
||||
exportService.confirmOutline(owner(), id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
private PersonalOwner owner() {
|
||||
return ownerProvider.current();
|
||||
}
|
||||
|
||||
+17
@@ -90,6 +90,23 @@ public final class PersonalAssistantDto {
|
||||
LocalDateTime createdAt) {
|
||||
}
|
||||
|
||||
public record ExportOutlineCreateRequest(long sessionId, String title) {
|
||||
}
|
||||
|
||||
public record ExportOutlineSection(String title, List<String> bullets) {
|
||||
}
|
||||
|
||||
public record ExportOutlineUpdateRequest(String title, List<ExportOutlineSection> sections) {
|
||||
}
|
||||
|
||||
public record ExportOutlineSource(String domain, String sourceId, Long itemId, String title, String excerpt) {
|
||||
}
|
||||
|
||||
public record ExportOutlineResponse(long id, long sessionId, String title, List<ExportOutlineSection> sections,
|
||||
List<ExportOutlineSource> sources, boolean confirmed, String status,
|
||||
Long ossId) {
|
||||
}
|
||||
|
||||
private static Long legacyId(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
try {
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package org.dromara.aihr.personal.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.CitationResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreateRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSection;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSource;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
||||
import org.dromara.aihr.personal.support.PersonalOwner;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
@Service
|
||||
public class PersonalExportService {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final LongSupplier idSupplier;
|
||||
|
||||
@Autowired
|
||||
public PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
this(jdbc, objectMapper, IdWorker::getId);
|
||||
}
|
||||
|
||||
private PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper, LongSupplier idSupplier) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.idSupplier = idSupplier;
|
||||
}
|
||||
|
||||
public static PersonalExportService forTest(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
LongSupplier idSupplier) {
|
||||
return new PersonalExportService(jdbc, objectMapper, idSupplier);
|
||||
}
|
||||
|
||||
public ExportOutlineResponse createOutline(PersonalOwner owner, ExportOutlineCreateRequest request) {
|
||||
requireOwner(owner);
|
||||
if (request == null || request.sessionId() <= 0) throw new ServiceException("PERSONAL_SESSION_NOT_FOUND");
|
||||
List<Map<String, Object>> sessions = jdbc.queryForList("""
|
||||
select title from aihr_personal_chat_session
|
||||
where binary tenant_id = binary ? and owner_user_id = ? and id = ? and status = 'ACTIVE'
|
||||
limit 1
|
||||
""", owner.tenantId(), owner.userId(), request.sessionId());
|
||||
if (sessions.size() != 1) throw new ServiceException("PERSONAL_SESSION_NOT_FOUND");
|
||||
List<Map<String, Object>> messages = jdbc.queryForList("""
|
||||
select content, citations_json from aihr_personal_chat_message
|
||||
where binary tenant_id = binary ? and owner_user_id = ? and session_id = ? and role = 'assistant'
|
||||
order by create_time desc, id desc limit 1
|
||||
""", owner.tenantId(), owner.userId(), request.sessionId());
|
||||
if (messages.size() != 1) throw new ServiceException("PERSONAL_EXPORT_SOURCE_REQUIRED");
|
||||
String content = clean(String.valueOf(messages.get(0).get("content")));
|
||||
List<ExportOutlineSource> sources = sources(messages.get(0).get("citations_json"));
|
||||
if (content.isEmpty() || sources.isEmpty()) throw new ServiceException("PERSONAL_EXPORT_SOURCE_REQUIRED");
|
||||
String sessionTitle = clean(String.valueOf(sessions.get(0).get("title")));
|
||||
String title = clean(request.title()).isEmpty() ? sessionTitle : clean(request.title());
|
||||
if (title.isEmpty()) title = "个人工作汇报";
|
||||
List<ExportOutlineSection> sections = List.of(new ExportOutlineSection("核心结论", bullets(content)));
|
||||
long id = positiveId(idSupplier.getAsLong());
|
||||
ExportOutlineResponse response = new ExportOutlineResponse(id, request.sessionId(), title, sections,
|
||||
sources, false, "DRAFT", null);
|
||||
int inserted = jdbc.update("""
|
||||
insert into aihr_personal_export_task
|
||||
(id, tenant_id, owner_user_id, session_id, outline_json, outline_confirmed,
|
||||
template_code, status, create_time, update_time)
|
||||
values (?, ?, ?, ?, ?, 0, 'standard', 'DRAFT', now(), now())
|
||||
""", id, owner.tenantId(), owner.userId(), request.sessionId(), json(response));
|
||||
if (inserted != 1) throw new ServiceException("PERSONAL_EXPORT_CREATE_FAILED");
|
||||
return response;
|
||||
}
|
||||
|
||||
public ExportOutlineResponse outline(PersonalOwner owner, long exportId) {
|
||||
return load(owner, exportId);
|
||||
}
|
||||
|
||||
public ExportOutlineResponse updateOutline(PersonalOwner owner, long exportId,
|
||||
ExportOutlineUpdateRequest request) {
|
||||
ExportOutlineResponse current = load(owner, exportId);
|
||||
if (current.confirmed() || !"DRAFT".equals(current.status())) {
|
||||
throw new ServiceException("PERSONAL_OUTLINE_ALREADY_CONFIRMED");
|
||||
}
|
||||
String title = clean(request == null ? null : request.title());
|
||||
List<ExportOutlineSection> sections = sanitizeSections(request == null ? null : request.sections());
|
||||
if (title.isEmpty() || sections.isEmpty()) throw new ServiceException("PERSONAL_OUTLINE_INVALID");
|
||||
ExportOutlineResponse updated = new ExportOutlineResponse(current.id(), current.sessionId(), title,
|
||||
sections, current.sources(), false, "DRAFT", null);
|
||||
int changed = jdbc.update("""
|
||||
update aihr_personal_export_task set outline_json = ?, update_time = now()
|
||||
where binary tenant_id = binary ? and owner_user_id = ? and id = ?
|
||||
and outline_confirmed = 0 and status = 'DRAFT'
|
||||
""", json(updated), owner.tenantId(), owner.userId(), exportId);
|
||||
if (changed != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
|
||||
return updated;
|
||||
}
|
||||
|
||||
public void confirmOutline(PersonalOwner owner, long exportId) {
|
||||
requireOwner(owner);
|
||||
if (exportId <= 0) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
|
||||
int changed = jdbc.update("""
|
||||
update aihr_personal_export_task
|
||||
set outline_confirmed = 1, status = 'CONFIRMED', confirmed_at = now(), update_time = now()
|
||||
where binary tenant_id = binary ? and owner_user_id = ? and id = ?
|
||||
and outline_confirmed = 0 and status = 'DRAFT'
|
||||
""", owner.tenantId(), owner.userId(), exportId);
|
||||
if (changed != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
|
||||
}
|
||||
|
||||
private ExportOutlineResponse load(PersonalOwner owner, long exportId) {
|
||||
requireOwner(owner);
|
||||
if (exportId <= 0) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
|
||||
List<Map<String, Object>> rows = jdbc.queryForList("""
|
||||
select outline_json, outline_confirmed, status, session_id, oss_id
|
||||
from aihr_personal_export_task
|
||||
where binary tenant_id = binary ? and owner_user_id = ? and id = ?
|
||||
limit 1
|
||||
""", owner.tenantId(), owner.userId(), exportId);
|
||||
if (rows.size() != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
|
||||
try {
|
||||
Map<String, Object> row = rows.get(0);
|
||||
ExportOutlineResponse outline = objectMapper.readValue(String.valueOf(row.get("outline_json")),
|
||||
ExportOutlineResponse.class);
|
||||
return new ExportOutlineResponse(outline.id(), outline.sessionId(), outline.title(),
|
||||
outline.sections(), outline.sources(), truthy(row.get("outline_confirmed")),
|
||||
clean(String.valueOf(row.get("status"))), nullableLong(row.get("oss_id")));
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new ServiceException("PERSONAL_OUTLINE_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
private List<ExportOutlineSource> sources(Object raw) {
|
||||
if (raw == null) return List.of();
|
||||
try {
|
||||
CitationResponse[] citations = objectMapper.readValue(String.valueOf(raw), CitationResponse[].class);
|
||||
return Arrays.stream(citations)
|
||||
.filter(citation -> citation != null && citation.domain() != null && citation.title() != null)
|
||||
.map(citation -> new ExportOutlineSource(citation.domain(), citation.sourceId(), citation.itemId(),
|
||||
clean(citation.title()), truncate(clean(citation.excerpt()), 300)))
|
||||
.toList();
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new ServiceException("PERSONAL_EXPORT_SOURCE_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> bullets(String content) {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (String line : content.split("\\R+")) {
|
||||
String value = clean(line).replaceFirst("^[#*\\-\\d.、\\s]+", "");
|
||||
if (!value.isEmpty()) result.add(truncate(value, 240));
|
||||
if (result.size() == 8) break;
|
||||
}
|
||||
if (result.isEmpty()) result.add(truncate(content, 240));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static List<ExportOutlineSection> sanitizeSections(List<ExportOutlineSection> sections) {
|
||||
if (sections == null) return List.of();
|
||||
List<ExportOutlineSection> result = new ArrayList<>();
|
||||
for (ExportOutlineSection section : sections) {
|
||||
if (section == null) continue;
|
||||
String title = truncate(clean(section.title()), 80);
|
||||
if (title.isEmpty()) continue;
|
||||
List<String> bullets = section.bullets() == null ? List.of() : section.bullets().stream()
|
||||
.map(PersonalExportService::clean).filter(value -> !value.isEmpty())
|
||||
.limit(12).map(value -> truncate(value, 240)).toList();
|
||||
if (!bullets.isEmpty()) result.add(new ExportOutlineSection(title, bullets));
|
||||
if (result.size() == 12) break;
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new ServiceException("PERSONAL_EXPORT_CREATE_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireOwner(PersonalOwner owner) {
|
||||
if (owner == null || owner.userId() <= 0 || clean(owner.tenantId()).isEmpty()) {
|
||||
throw new ServiceException("PERSONAL_OWNER_REQUIRED");
|
||||
}
|
||||
}
|
||||
|
||||
private static long positiveId(long id) {
|
||||
if (id <= 0) throw new ServiceException("PERSONAL_EXPORT_CREATE_FAILED");
|
||||
return id;
|
||||
}
|
||||
|
||||
private static boolean truthy(Object value) {
|
||||
if (value instanceof Boolean bool) return bool;
|
||||
if (value instanceof Number number) return number.intValue() == 1;
|
||||
return "1".equals(clean(String.valueOf(value))) || "true".equalsIgnoreCase(clean(String.valueOf(value)));
|
||||
}
|
||||
|
||||
private static Long nullableLong(Object value) {
|
||||
if (value instanceof Number number) return number.longValue();
|
||||
String text = clean(String.valueOf(value));
|
||||
if (text.isEmpty()) return null;
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
return value == null || "null".equals(value) ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String truncate(String value, int max) {
|
||||
return value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -4,9 +4,12 @@ import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import org.dromara.aihr.personal.controller.PersonalAssistantController;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.TextItemRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.UrlItemRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreateRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
||||
import org.dromara.aihr.personal.service.PersonalAnswerService;
|
||||
import org.dromara.aihr.personal.service.PersonalCleanupService;
|
||||
import org.dromara.aihr.personal.service.PersonalIngestionService;
|
||||
import org.dromara.aihr.personal.service.PersonalExportService;
|
||||
import org.dromara.aihr.personal.service.PersonalRetrievalService;
|
||||
import org.dromara.aihr.personal.service.PersonalSpaceService;
|
||||
import org.dromara.aihr.personal.service.PersonalUrlFetchService;
|
||||
@@ -46,12 +49,13 @@ class PersonalAssistantControllerTest {
|
||||
PersonalCleanupService cleanup = mock(PersonalCleanupService.class);
|
||||
org.dromara.aihr.personal.service.PersonalPdfOcrService pdfOcr =
|
||||
mock(org.dromara.aihr.personal.service.PersonalPdfOcrService.class);
|
||||
PersonalExportService exports = mock(PersonalExportService.class);
|
||||
when(urls.fetch("https://example.com/a")).thenReturn(new PersonalUrlFetchService.FetchResult(
|
||||
java.net.URI.create("https://example.com/a"), 200, "text/plain", "a".getBytes(),
|
||||
java.time.Instant.now(), "hash"));
|
||||
|
||||
PersonalAssistantController controller = new PersonalAssistantController(owners, spaces, ingestion, urls,
|
||||
retrieval, answers, cleanup, pdfOcr);
|
||||
retrieval, answers, cleanup, pdfOcr, exports);
|
||||
controller.space();
|
||||
controller.items(1, 20, null, null, null, null, null);
|
||||
controller.createText(new TextItemRequest("note", "body", null, List.of()));
|
||||
@@ -67,8 +71,12 @@ class PersonalAssistantControllerTest {
|
||||
controller.sessions();
|
||||
controller.session(3L);
|
||||
controller.deleteSession(3L);
|
||||
controller.createOutline(new ExportOutlineCreateRequest(3L, "工作汇报"));
|
||||
controller.outline(7L);
|
||||
controller.updateOutline(7L, new ExportOutlineUpdateRequest("工作汇报", List.of()));
|
||||
controller.confirmOutline(7L);
|
||||
|
||||
verify(owners, times(15)).current();
|
||||
verify(owners, times(19)).current();
|
||||
assertFalse(PersonalAssistantController.class.isAnnotationPresent(SaIgnore.class));
|
||||
for (var method : PersonalAssistantController.class.getDeclaredMethods()) {
|
||||
assertFalse(method.isAnnotationPresent(SaIgnore.class), method.getName());
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package org.dromara.aihr.personal;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreateRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSection;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
||||
import org.dromara.aihr.personal.service.PersonalExportService;
|
||||
import org.dromara.aihr.personal.support.PersonalOwner;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@Tag("dev")
|
||||
class PersonalExportServiceTest {
|
||||
|
||||
private static final PersonalOwner OWNER = new PersonalOwner("000000", 101L, "13800000000");
|
||||
|
||||
@Test
|
||||
void productionConstructorIsExplicitlyAutowired() {
|
||||
assertTrue(List.of(PersonalExportService.class.getDeclaredConstructors()).stream()
|
||||
.filter(constructor -> constructor.getParameterCount() == 2)
|
||||
.map(Constructor::getAnnotations)
|
||||
.flatMap(Arrays::stream)
|
||||
.anyMatch(annotation -> annotation.annotationType() == Autowired.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outlineCreationUsesOwnedSessionAndPersistsAuthorizedSources() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForList(contains("from aihr_personal_chat_session"),
|
||||
eq("000000"), eq(101L), eq(77L))).thenReturn(List.of(Map.of("title", "保洁改进讨论")));
|
||||
when(jdbc.queryForList(contains("from aihr_personal_chat_message"),
|
||||
eq("000000"), eq(101L), eq(77L))).thenReturn(List.of(Map.of(
|
||||
"content", "先统一检查标准,再明确整改责任人。",
|
||||
"citations_json", "[{\"domain\":\"PERSONAL\",\"sourceId\":\"9\",\"itemId\":9,\"title\":\"保洁检查记录\",\"excerpt\":\"每日复查\"}]")));
|
||||
when(jdbc.update(contains("insert into aihr_personal_export_task"),
|
||||
anyLong(), anyString(), anyLong(), anyLong(), anyString())).thenReturn(1);
|
||||
PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9001L);
|
||||
|
||||
ExportOutlineResponse result = service.createOutline(OWNER,
|
||||
new ExportOutlineCreateRequest(77L, "保洁管理改进建议"));
|
||||
|
||||
assertEquals(9001L, result.id());
|
||||
assertEquals("保洁管理改进建议", result.title());
|
||||
assertEquals("保洁检查记录", result.sources().get(0).title());
|
||||
verify(jdbc).update(contains("insert into aihr_personal_export_task"),
|
||||
eq(9001L), eq("000000"), eq(101L), eq(77L), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outlineCreationRejectsSessionOwnedByAnotherUser() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForList(contains("from aihr_personal_chat_session"),
|
||||
eq("000000"), eq(101L), eq(77L))).thenReturn(List.of());
|
||||
PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9001L);
|
||||
|
||||
assertThrows(RuntimeException.class, () -> service.createOutline(OWNER,
|
||||
new ExportOutlineCreateRequest(77L, "保洁管理改进建议")));
|
||||
|
||||
verify(jdbc, never()).update(contains("insert into aihr_personal_export_task"),
|
||||
anyLong(), anyString(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editingDraftPreservesServerAuthorizedSources() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
String stored = "{\"id\":9001,\"sessionId\":77,\"title\":\"旧标题\",\"sections\":[{\"title\":\"核心结论\",\"bullets\":[\"旧内容\"]}],\"sources\":[{\"domain\":\"PERSONAL\",\"sourceId\":\"9\",\"itemId\":9,\"title\":\"保洁检查记录\",\"excerpt\":\"每日复查\"}],\"confirmed\":false,\"status\":\"DRAFT\",\"ossId\":null}";
|
||||
when(jdbc.queryForList(contains("from aihr_personal_export_task"),
|
||||
eq("000000"), eq(101L), eq(9001L))).thenReturn(List.of(Map.of(
|
||||
"outline_json", stored, "outline_confirmed", 0, "status", "DRAFT",
|
||||
"session_id", 77L)));
|
||||
when(jdbc.update(contains("set outline_json = ?"), anyString(),
|
||||
eq("000000"), eq(101L), eq(9001L))).thenReturn(1);
|
||||
PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L);
|
||||
|
||||
ExportOutlineResponse result = service.updateOutline(OWNER, 9001L,
|
||||
new ExportOutlineUpdateRequest("新标题", List.of(
|
||||
new ExportOutlineSection("行动计划", List.of("明确责任人", "下周复盘")))));
|
||||
|
||||
assertEquals("新标题", result.title());
|
||||
assertEquals("保洁检查记录", result.sources().get(0).title());
|
||||
assertEquals("行动计划", result.sections().get(0).title());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmingDraftIsOwnerScopedAndFreezesOutline() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.update(contains("outline_confirmed = 1"),
|
||||
eq("000000"), eq(101L), eq(9001L))).thenReturn(1);
|
||||
PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L);
|
||||
|
||||
service.confirmOutline(OWNER, 9001L);
|
||||
|
||||
verify(jdbc).update(contains("status = 'CONFIRMED'"),
|
||||
eq("000000"), eq(101L), eq(9001L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readingOutlineUsesDatabaseWorkflowStateInsteadOfStaleJsonState() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
String stored = "{\"id\":9001,\"sessionId\":77,\"title\":\"保洁改进\",\"sections\":[{\"title\":\"核心结论\",\"bullets\":[\"明确标准\"]}],\"sources\":[{\"domain\":\"PERSONAL\",\"sourceId\":\"9\",\"itemId\":9,\"title\":\"保洁检查记录\",\"excerpt\":\"每日复查\"}],\"confirmed\":false,\"status\":\"DRAFT\",\"ossId\":null}";
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("outline_json", stored);
|
||||
row.put("outline_confirmed", 1);
|
||||
row.put("status", "CONFIRMED");
|
||||
row.put("session_id", 77L);
|
||||
row.put("oss_id", 5566L);
|
||||
when(jdbc.queryForList(contains("from aihr_personal_export_task"),
|
||||
eq("000000"), eq(101L), eq(9001L))).thenReturn(List.of(row));
|
||||
PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L);
|
||||
|
||||
ExportOutlineResponse result = service.outline(OWNER, 9001L);
|
||||
|
||||
assertTrue(result.confirmed());
|
||||
assertEquals("CONFIRMED", result.status());
|
||||
assertEquals(5566L, result.ossId());
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -61,8 +61,11 @@ class PersonalSchemaContractTest {
|
||||
String cleanup = tableDefinition(sql, "aihr_personal_cleanup_job");
|
||||
String ocrJob = tableDefinition(sql, "aihr_personal_ocr_job");
|
||||
String ocrPage = tableDefinition(sql, "aihr_personal_ocr_page");
|
||||
String exportTask = tableDefinition(sql, "aihr_personal_export_task");
|
||||
|
||||
for (String definition : new String[] {space, item, fragment, session, message, cleanup, ocrJob, ocrPage}) {
|
||||
for (String definition : new String[] {
|
||||
space, item, fragment, session, message, cleanup, ocrJob, ocrPage, exportTask
|
||||
}) {
|
||||
assertTrue(definition.contains("`owner_user_id` bigint not null"),
|
||||
"Every personal table must carry a non-null owner_user_id");
|
||||
}
|
||||
@@ -139,6 +142,16 @@ class PersonalSchemaContractTest {
|
||||
assertTrue(ocrPage.contains("unique key `uk_personal_ocr_page_number` (`tenant_id`, `owner_user_id`, `item_id`, `page_number`)"));
|
||||
assertTrue(ocrPage.contains("key `idx_personal_ocr_page_status` (`status`, `update_time`)"));
|
||||
|
||||
assertTrue(exportTask.contains("`session_id` bigint not null"));
|
||||
assertTrue(exportTask.contains("`outline_json` json not null"));
|
||||
assertTrue(exportTask.contains("`outline_confirmed` tinyint(1) not null default 0"));
|
||||
assertTrue(exportTask.contains("`template_code` varchar(50) not null default 'standard'"));
|
||||
assertTrue(exportTask.contains("`status` varchar(20) not null default 'draft'"));
|
||||
assertTrue(exportTask.contains("`oss_id` bigint default null"));
|
||||
assertTrue(exportTask.contains(
|
||||
"key `idx_personal_export_owner` (`tenant_id`, `owner_user_id`, `update_time`)"));
|
||||
assertTrue(exportTask.contains("key `idx_personal_export_status` (`status`, `update_time`)"));
|
||||
|
||||
assertFalse(sql.contains("alter table aihr_knowledge_fragment"),
|
||||
"Personal schema must not mutate enterprise knowledge tables");
|
||||
assertFalse(sql.contains("alter table `aihr_knowledge_fragment`"),
|
||||
|
||||
@@ -167,3 +167,22 @@ CREATE TABLE IF NOT EXISTS `aihr_personal_ocr_page` (
|
||||
UNIQUE KEY `uk_personal_ocr_page_number` (`tenant_id`, `owner_user_id`, `item_id`, `page_number`),
|
||||
KEY `idx_personal_ocr_page_status` (`status`, `update_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理扫描PDF OCR页结果';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_personal_export_task` (
|
||||
`id` bigint NOT NULL COMMENT '导出任务ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`owner_user_id` bigint NOT NULL COMMENT '任务所属用户ID',
|
||||
`session_id` bigint NOT NULL COMMENT '来源会话ID',
|
||||
`outline_json` json NOT NULL COMMENT '可编辑大纲及不可变来源JSON',
|
||||
`outline_confirmed` tinyint(1) NOT NULL DEFAULT 0 COMMENT '大纲是否已确认',
|
||||
`template_code` varchar(50) NOT NULL DEFAULT 'standard' COMMENT 'PPT模板代码',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/CONFIRMED/PENDING/RUNNING/READY/FAILED',
|
||||
`oss_id` bigint DEFAULT NULL COMMENT '生成PPTX的私有OSS ID',
|
||||
`error_message` varchar(500) DEFAULT NULL COMMENT '公开错误信息',
|
||||
`confirmed_at` datetime DEFAULT NULL COMMENT '大纲确认时间',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_personal_export_owner` (`tenant_id`, `owner_user_id`, `update_time`),
|
||||
KEY `idx_personal_export_status` (`status`, `update_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理汇报导出任务';
|
||||
|
||||
@@ -60,14 +60,37 @@
|
||||
</button>
|
||||
</view>
|
||||
<view v-else class="empty">本次回答没有可展示的引用,不补造来源。</view>
|
||||
<button v-if="answer.citations?.length" class="secondary-button outline-create" :disabled="outlineBusy" @click="generateOutline">
|
||||
{{ outlineBusy ? '正在生成提纲' : '生成汇报提纲' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="outline" class="panel employee-card outline-panel">
|
||||
<view class="outline-head"><text class="panel-title">汇报提纲</text><text class="outline-state">{{ outline.confirmed ? '已确认' : '可编辑' }}</text></view>
|
||||
<input v-model="outline.title" class="outline-input outline-title" :disabled="outline.confirmed" maxlength="120" placeholder="汇报标题" />
|
||||
<view v-for="(section, sectionIndex) in outline.sections" :key="sectionIndex" class="outline-section">
|
||||
<input v-model="section.title" class="outline-input section-title" :disabled="outline.confirmed" maxlength="80" placeholder="章节标题" />
|
||||
<input v-for="(_bullet, bulletIndex) in section.bullets" :key="bulletIndex" v-model="section.bullets[bulletIndex]" class="outline-input bullet-input" :disabled="outline.confirmed" maxlength="240" placeholder="要点" />
|
||||
<button v-if="!outline.confirmed" class="text-button" @click="addOutlineBullet(sectionIndex)">+ 添加要点</button>
|
||||
</view>
|
||||
<button v-if="!outline.confirmed" class="text-button" @click="addOutlineSection">+ 添加章节</button>
|
||||
<view class="outline-sources">
|
||||
<text class="citation-heading">资料来源不可编辑</text>
|
||||
<text v-for="source in outline.sources" :key="`${source.domain}-${source.sourceId}`" class="outline-source">{{ source.domain === 'ENTERPRISE' ? '企业知识' : '我的资料' }} · {{ source.title }}</text>
|
||||
</view>
|
||||
<view v-if="!outline.confirmed" class="outline-actions">
|
||||
<button class="secondary-button" :disabled="outlineBusy" @click="saveOutline">保存修改</button>
|
||||
<button class="primary-button" :disabled="outlineBusy" @click="confirmOutline">确认大纲</button>
|
||||
</view>
|
||||
<text v-if="outlineMessage" class="status-message">{{ outlineMessage }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { PersonalAskResponse, PersonalCitation } from '@/types/api';
|
||||
import { askPersonalAssistant, type KnowledgeScope } from '@/services/personal-assistant';
|
||||
import type { PersonalAskResponse, PersonalCitation, PersonalExportOutline } from '@/types/api';
|
||||
import { askPersonalAssistant, confirmPersonalOutline, createPersonalOutline, updatePersonalOutline, type KnowledgeScope } from '@/services/personal-assistant';
|
||||
import { personalItemRoute } from '@/services/personal-assistant-id';
|
||||
|
||||
const scopes = ref<KnowledgeScope[]>(['PERSONAL']);
|
||||
@@ -75,6 +98,9 @@ const queryText = ref('');
|
||||
const asking = ref(false);
|
||||
const answer = ref<PersonalAskResponse>();
|
||||
const message = ref('');
|
||||
const outline = ref<PersonalExportOutline>();
|
||||
const outlineBusy = ref(false);
|
||||
const outlineMessage = ref('');
|
||||
let requestGeneration = 0;
|
||||
const enterpriseEnabled = computed(() => scopes.value.includes('ENTERPRISE'));
|
||||
|
||||
@@ -83,6 +109,7 @@ const changeScope = () => {
|
||||
if (asking.value) return;
|
||||
requestGeneration += 1;
|
||||
answer.value = undefined;
|
||||
outline.value = undefined;
|
||||
message.value = '';
|
||||
scopes.value = enterpriseEnabled.value ? ['PERSONAL'] : ['PERSONAL', 'ENTERPRISE'];
|
||||
};
|
||||
@@ -94,6 +121,7 @@ const submitQuestion = async () => {
|
||||
const scopeSnapshot = [...scopes.value];
|
||||
asking.value = true;
|
||||
answer.value = undefined;
|
||||
outline.value = undefined;
|
||||
message.value = '';
|
||||
try {
|
||||
const response = await askPersonalAssistant({ queryText: question, scope: scopeSnapshot, outputFormat: 'ACTION_PLAN' });
|
||||
@@ -123,6 +151,64 @@ const submitQuestion = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const generateOutline = async () => {
|
||||
if (!answer.value?.sessionId || !answer.value.citations?.length || outlineBusy.value) return;
|
||||
outlineBusy.value = true;
|
||||
outlineMessage.value = '';
|
||||
try {
|
||||
outline.value = await createPersonalOutline({ sessionId: answer.value.sessionId, title: queryText.value.trim() });
|
||||
} catch (error) {
|
||||
outlineMessage.value = error instanceof Error ? error.message : '提纲生成失败';
|
||||
} finally {
|
||||
outlineBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const outlinePayload = () => outline.value ? {
|
||||
title: outline.value.title.trim(),
|
||||
sections: outline.value.sections.map((section) => ({
|
||||
title: section.title.trim(),
|
||||
bullets: section.bullets.map((bullet) => bullet.trim()).filter(Boolean)
|
||||
})).filter((section) => section.title && section.bullets.length)
|
||||
} : undefined;
|
||||
|
||||
const saveOutline = async () => {
|
||||
const payload = outlinePayload();
|
||||
if (!outline.value || !payload || !payload.title || !payload.sections.length || outlineBusy.value) {
|
||||
outlineMessage.value = '请保留标题和至少一个有效章节';
|
||||
return;
|
||||
}
|
||||
outlineBusy.value = true;
|
||||
outlineMessage.value = '';
|
||||
try {
|
||||
outline.value = await updatePersonalOutline(outline.value.id, payload);
|
||||
outlineMessage.value = '提纲已保存';
|
||||
} catch (error) {
|
||||
outlineMessage.value = error instanceof Error ? error.message : '提纲保存失败';
|
||||
} finally {
|
||||
outlineBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmOutline = async () => {
|
||||
await saveOutline();
|
||||
if (!outline.value || outlineMessage.value !== '提纲已保存') return;
|
||||
outlineBusy.value = true;
|
||||
try {
|
||||
await confirmPersonalOutline(outline.value.id);
|
||||
outline.value.confirmed = true;
|
||||
outline.value.status = 'CONFIRMED';
|
||||
outlineMessage.value = '大纲已确认,内容已冻结';
|
||||
} catch (error) {
|
||||
outlineMessage.value = error instanceof Error ? error.message : '大纲确认失败';
|
||||
} finally {
|
||||
outlineBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addOutlineSection = () => outline.value?.sections.push({ title: '', bullets: [''] });
|
||||
const addOutlineBullet = (sectionIndex: number) => outline.value?.sections[sectionIndex]?.bullets.push('');
|
||||
|
||||
const citationLabel = (citation: PersonalCitation) => {
|
||||
if (citation.domain === 'ENTERPRISE') return '企业 SOP';
|
||||
return citation.sourceType === 'URL' ? '外部网页' : '我的资料';
|
||||
@@ -160,4 +246,5 @@ const openSessions = () => uni.navigateTo({ url: '/pages/user/assistant/sessions
|
||||
.domain-badge.enterprise { background: #fff2e6; color: var(--warning); }
|
||||
.citation-title { min-width: 0; overflow: hidden; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.citation-excerpt { margin-top: 7px; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
.outline-create { width: 100%; margin-top: 14px; }.outline-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.outline-state { color: var(--brand); font-size: 12px; font-weight: 800; }.outline-input { width: 100%; min-height: 42px; margin-top: 10px; padding: 0 12px; border: 1px solid var(--line); background: #fff; color: var(--ink); }.outline-title { font-size: 17px; font-weight: 900; }.outline-section { margin-top: 14px; padding: 12px; border: 1px solid var(--line); background: #f8fafc; }.section-title { margin-top: 0; font-weight: 800; }.bullet-input { font-size: 13px; }.text-button { margin-top: 8px; padding: 4px 0; border: 0; background: transparent; color: var(--brand); font-size: 13px; }.outline-sources { margin-top: 14px; }.outline-source { display: block; margin-top: 6px; color: var(--muted); font-size: 12px; }.outline-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }.outline-actions button { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
PersonalItem,
|
||||
PersonalItemCreated,
|
||||
PersonalItemSourceType,
|
||||
PersonalExportOutline,
|
||||
PersonalExportOutlineSection,
|
||||
PersonalKnowledgeScope,
|
||||
PersonalPage,
|
||||
PersonalSession,
|
||||
@@ -164,3 +166,24 @@ export const deletePersonalSession = (id: string) => apiRequest<void>({
|
||||
url: personalSessionApiPath(id),
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
export const createPersonalOutline = (payload: { sessionId: string; title?: string }) =>
|
||||
apiRequest<PersonalExportOutline>({
|
||||
url: '/api/aihr/personal-assistant/exports/outline',
|
||||
method: 'POST',
|
||||
data: payload
|
||||
});
|
||||
|
||||
export const updatePersonalOutline = (id: string, payload: {
|
||||
title: string;
|
||||
sections: PersonalExportOutlineSection[];
|
||||
}) => apiRequest<PersonalExportOutline>({
|
||||
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/outline`,
|
||||
method: 'PUT',
|
||||
data: payload
|
||||
});
|
||||
|
||||
export const confirmPersonalOutline = (id: string) => apiRequest<void>({
|
||||
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/confirm`,
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
@@ -139,6 +139,30 @@ export interface PersonalAskResponse {
|
||||
promptVersion?: string;
|
||||
}
|
||||
|
||||
export interface PersonalExportOutlineSection {
|
||||
title: string;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export interface PersonalExportOutlineSource {
|
||||
domain: PersonalKnowledgeScope;
|
||||
sourceId: string;
|
||||
itemId?: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
}
|
||||
|
||||
export interface PersonalExportOutline {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
title: string;
|
||||
sections: PersonalExportOutlineSection[];
|
||||
sources: PersonalExportOutlineSource[];
|
||||
confirmed: boolean;
|
||||
status: string;
|
||||
ossId?: string;
|
||||
}
|
||||
|
||||
export interface PersonalSpace {
|
||||
spaceId: string;
|
||||
status: string;
|
||||
|
||||
@@ -53,6 +53,20 @@ test('问答首页默认只查询个人资料并展示真实引用域', async ()
|
||||
assert.doesNotMatch(page, /ownerUserId|tenantId/);
|
||||
});
|
||||
|
||||
test('个人助理先生成可编辑大纲并在确认后冻结来源', async () => {
|
||||
const [page, client] = await Promise.all([
|
||||
source('../src/pages/user/assistant/index.vue'),
|
||||
source('../src/services/personal-assistant.ts')
|
||||
]);
|
||||
assert.match(client, /createPersonalOutline/);
|
||||
assert.match(client, /updatePersonalOutline/);
|
||||
assert.match(client, /confirmPersonalOutline/);
|
||||
assert.match(page, /生成汇报提纲/);
|
||||
assert.match(page, /确认大纲/);
|
||||
assert.match(page, /资料来源不可编辑/);
|
||||
assert.match(page, /answer\.citations\?\.length/);
|
||||
});
|
||||
|
||||
test('资料库和收藏页覆盖分页筛选以及三种真实收藏方式', async () => {
|
||||
const [library, capture] = await Promise.all([
|
||||
source('../src/pages/user/assistant/library.vue'),
|
||||
|
||||
Reference in New Issue
Block a user