mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(skill,agent): curation observation anchor, per-skill adoption, and empty plan-summary fallback
This commit is contained in:
parent
99c18b968a
commit
c72c8b48e4
@ -85,32 +85,68 @@ public class PlanSummaryNode implements NodeAction {
|
||||
chatModel, prompt, conversationId, "plan_summary");
|
||||
|
||||
String summary = result.text();
|
||||
String thinking = result.thinking() == null ? "" : result.thinking();
|
||||
|
||||
// An interleaved-thinking model can spend its whole turn reasoning and
|
||||
// return empty text. That empty string used to pass straight through:
|
||||
// it became the plan's summary, then the run's terminal answer, and the
|
||||
// goal evaluator skipped on "terminalAnswer empty" — so a plan whose
|
||||
// steps had all succeeded ended with a dangling reasoning block and no
|
||||
// report. The step results are already in hand, so answer from those
|
||||
// rather than hand back nothing.
|
||||
if (summary == null || summary.isBlank()) {
|
||||
log.warn("[PlanSummary] Plan {} produced an empty summary "
|
||||
+ "(thinking={} chars, {} step results); falling back to step results",
|
||||
planId, thinking.length(), completedResults.size());
|
||||
summary = buildFallbackSummary(goal, completedResults, SUMMARY_EMPTY_NOTE);
|
||||
}
|
||||
|
||||
// The steps themselves succeeded — only the summary text was missing —
|
||||
// so the plan completes rather than being marked failed.
|
||||
planningService.completePlan(planId, summary);
|
||||
log.info("[PlanSummary] Plan {} completed with summary: {}",
|
||||
planId, summary.length() > 100 ? summary.substring(0, 100) + "..." : summary);
|
||||
planId, truncate(summary, 100));
|
||||
|
||||
return PlanStateAccessor.output()
|
||||
.finalSummary(summary)
|
||||
.finalSummaryThinking(result.thinking())
|
||||
.finalSummaryThinking(thinking)
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.thinkingStreamed(!thinking.isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[PlanSummary] Failed to summarize plan {}: {}", planId, e.getMessage(), e);
|
||||
String fallbackSummary = buildFallbackSummary(goal, completedResults);
|
||||
String fallbackSummary = buildFallbackSummary(goal, completedResults, SUMMARY_FAILED_NOTE);
|
||||
planningService.markPlanFailed(planId, "汇总阶段失败:" + truncate(e.getMessage(), 100));
|
||||
return Map.of(PlanStateKeys.FINAL_SUMMARY, fallbackSummary);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reason line for the summary call throwing. */
|
||||
private static final String SUMMARY_FAILED_NOTE = "LLM 汇总失败,以下为步骤原始结果";
|
||||
|
||||
/**
|
||||
* 在 LLM 汇总调用失败时生成本地 fallback 摘要。
|
||||
* 每条步骤结果截断至 300 字,避免把过长内容(包括错误体)直接暴露给用户。
|
||||
* Reason line for the summary call returning nothing. Distinct from the
|
||||
* failure note because nothing actually failed — the steps ran, the model
|
||||
* simply produced no text — and telling the user their run failed would be
|
||||
* wrong.
|
||||
*/
|
||||
private static String buildFallbackSummary(String goal, List<String> completedResults) {
|
||||
StringBuilder sb = new StringBuilder("目标:").append(goal).append("\n\n执行摘要(LLM 汇总失败,以下为步骤原始结果):\n");
|
||||
private static final String SUMMARY_EMPTY_NOTE = "模型未产出汇总正文,以下为步骤原始结果";
|
||||
|
||||
/**
|
||||
* 在 LLM 汇总不可用时生成本地 fallback 摘要。
|
||||
* 每条步骤结果截断至 300 字,避免把过长内容(包括错误体)直接暴露给用户。
|
||||
*
|
||||
* @param note 说明为何回落到步骤原始结果
|
||||
*/
|
||||
private static String buildFallbackSummary(String goal, List<String> completedResults, String note) {
|
||||
StringBuilder sb = new StringBuilder("目标:").append(goal)
|
||||
.append("\n\n执行摘要(").append(note).append("):\n");
|
||||
if (completedResults == null || completedResults.isEmpty()) {
|
||||
sb.append("(没有已完成的步骤结果可供汇总)\n");
|
||||
return sb.toString();
|
||||
}
|
||||
for (String r : completedResults) {
|
||||
sb.append(truncate(r, 300)).append("\n");
|
||||
}
|
||||
|
||||
@ -1149,6 +1149,61 @@ public class SkillController {
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "列出未纳入自治治理的技能")
|
||||
@GetMapping("/curator/unmanaged")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<List<Map<String, Object>>> curatorUnmanaged() {
|
||||
return R.ok(skillLifecycleService.listUnmanaged());
|
||||
}
|
||||
|
||||
@Operation(summary = "列出已纳入自治治理的技能")
|
||||
@GetMapping("/curator/managed")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<List<Map<String, Object>>> curatorManaged() {
|
||||
return R.ok(skillLifecycleService.listManaged());
|
||||
}
|
||||
|
||||
@Operation(summary = "将技能移交给自治治理(不重置闲置时钟)")
|
||||
@PostMapping("/curator/adopt")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> curatorAdopt(@RequestBody List<String> skillIds) {
|
||||
return R.ok(setAdoptedBulk(skillIds, true));
|
||||
}
|
||||
|
||||
@Operation(summary = "撤销移交,技能归还用户所有")
|
||||
@PostMapping("/curator/release")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Map<String, Object>> curatorRelease(@RequestBody List<String> skillIds) {
|
||||
return R.ok(setAdoptedBulk(skillIds, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply adopt/release across a batch, reporting per-skill outcomes rather
|
||||
* than failing the whole call on one bad id — a partial batch that silently
|
||||
* rolled back would leave the operator unsure which skills moved.
|
||||
*/
|
||||
private Map<String, Object> setAdoptedBulk(List<String> skillIds, boolean adopt) {
|
||||
List<String> changed = new ArrayList<>();
|
||||
List<Map<String, Object>> rejected = new ArrayList<>();
|
||||
for (String raw : skillIds == null ? List.<String>of() : skillIds) {
|
||||
// Ids stay strings end-to-end; parse once here so a malformed one
|
||||
// is a reported rejection rather than a framework-level failure.
|
||||
try {
|
||||
skillLifecycleService.setAdopted(Long.parseLong(String.valueOf(raw).strip()), adopt);
|
||||
changed.add(String.valueOf(raw));
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", String.valueOf(raw));
|
||||
row.put("message", e.getMessage());
|
||||
rejected.add(row);
|
||||
}
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("changed", changed);
|
||||
out.put("rejected", rejected);
|
||||
return out;
|
||||
}
|
||||
|
||||
@Operation(summary = "列出技能库还原点")
|
||||
@GetMapping("/curator/snapshots")
|
||||
@RequireWorkspaceRole("member")
|
||||
|
||||
@ -210,7 +210,24 @@ public class SkillCuratorJob {
|
||||
List<SkillEntity> candidates = loadCandidates();
|
||||
int plannedStale = 0, plannedArchived = 0, plannedReactivate = 0;
|
||||
int appliedStale = 0, appliedArchived = 0, appliedReactivate = 0;
|
||||
int newlyObserved = 0;
|
||||
for (SkillEntity skill : candidates) {
|
||||
// A candidate no sweep has seen before starts its idle clock now
|
||||
// rather than being judged on time it spent outside curation.
|
||||
// planTransition already returns NONE for these; stamping the
|
||||
// anchor is what lets the next sweep judge it for real.
|
||||
//
|
||||
// A dry run must not write, but it must still reach the same
|
||||
// verdict a real run would — this report is what an operator reads
|
||||
// to decide whether widening the scope is safe, so predicting
|
||||
// archives that a real run would defer would be a lie.
|
||||
if (SkillLifecycleService.isUnobserved(skill)) {
|
||||
newlyObserved++;
|
||||
if (!dryRun) {
|
||||
lifecycleService.markObserved(skill, now);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
LifecycleTransition t = lifecycleService.planTransition(skill, now);
|
||||
report.add(skill, t);
|
||||
if (t == LifecycleTransition.TO_STALE) {
|
||||
@ -236,6 +253,7 @@ public class SkillCuratorJob {
|
||||
}
|
||||
|
||||
report.scanned(candidates.size())
|
||||
.newlyObserved(newlyObserved)
|
||||
.plannedCounts(plannedStale, plannedArchived, plannedReactivate)
|
||||
.appliedCounts(appliedStale, appliedArchived, appliedReactivate)
|
||||
.blockedByBindings(agentBindingService.blockedByBindingCandidates(now));
|
||||
|
||||
@ -33,6 +33,7 @@ public class SkillCuratorReport {
|
||||
private final boolean dryRun;
|
||||
private final Config config;
|
||||
private final int scanned;
|
||||
private final int newlyObserved;
|
||||
private final Counts planned;
|
||||
private final Counts applied;
|
||||
private final List<TransitionRow> transitions;
|
||||
@ -50,6 +51,7 @@ public class SkillCuratorReport {
|
||||
this.dryRun = b.dryRun;
|
||||
this.config = new Config(b.staleAfterDays, b.archiveAfterDays, b.scope);
|
||||
this.scanned = b.scanned;
|
||||
this.newlyObserved = b.newlyObserved;
|
||||
this.planned = new Counts(b.plannedStale, b.plannedArchived, b.plannedReactivated);
|
||||
this.applied = new Counts(b.appliedStale, b.appliedArchived, b.appliedReactivated);
|
||||
this.transitions = List.copyOf(b.transitions);
|
||||
@ -62,6 +64,16 @@ public class SkillCuratorReport {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidates seen by curation for the first time this run. They are
|
||||
* deferred rather than judged, so an operator reading a first sweep
|
||||
* after widening the scope can tell "nothing was archived because it is
|
||||
* all brand new to the curator" from "nothing needed archiving".
|
||||
*/
|
||||
public int newlyObserved() {
|
||||
return newlyObserved;
|
||||
}
|
||||
|
||||
/** Applied count of skills marked stale (0 for a dry-run). */
|
||||
public int markedStale() {
|
||||
return applied.stale();
|
||||
@ -104,6 +116,7 @@ public class SkillCuratorReport {
|
||||
private int archiveAfterDays;
|
||||
private String scope;
|
||||
private int scanned;
|
||||
private int newlyObserved;
|
||||
private int plannedStale, plannedArchived, plannedReactivated;
|
||||
private int appliedStale, appliedArchived, appliedReactivated;
|
||||
private final List<TransitionRow> transitions = new ArrayList<>();
|
||||
@ -128,6 +141,11 @@ public class SkillCuratorReport {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder newlyObserved(int newlyObserved) {
|
||||
this.newlyObserved = newlyObserved;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder scanned(int scanned) {
|
||||
this.scanned = scanned;
|
||||
return this;
|
||||
@ -138,8 +156,9 @@ public class SkillCuratorReport {
|
||||
if (t == null || t == LifecycleTransition.NONE) {
|
||||
return this;
|
||||
}
|
||||
LocalDateTime anchor = skill.getLastActivityAt() != null
|
||||
? skill.getLastActivityAt() : skill.getCreateTime();
|
||||
// Shared with the decision path — a report that computed idle days
|
||||
// its own way could contradict the transition it is describing.
|
||||
LocalDateTime anchor = SkillLifecycleService.anchor(skill);
|
||||
long days = anchor == null || runAt == null ? 0L : Duration.between(anchor, runAt).toDays();
|
||||
String from = Optional.ofNullable(skill.getLifecycleState()).orElse("active");
|
||||
String to = switch (t) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package vip.mate.skill.lifecycle;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -9,6 +10,7 @@ import org.springframework.stereotype.Service;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.model.SkillOrigin;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
@ -16,6 +18,8 @@ import vip.mate.skill.workspace.SkillWorkspaceProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@ -68,14 +72,45 @@ public class SkillLifecycleService {
|
||||
|
||||
// ==================== Pure decision functions ====================
|
||||
|
||||
/** Activity anchor: last recorded activity, falling back to creation time. */
|
||||
public LocalDateTime anchor(SkillEntity skill) {
|
||||
/**
|
||||
* Activity anchor, in order of preference: real recorded activity, then
|
||||
* the moment curation first saw the skill, then creation time.
|
||||
*
|
||||
* <p>The middle term is what keeps a newly-eligible skill from being
|
||||
* judged on time it spent outside curation entirely. Creation time stays
|
||||
* as the final fallback so rows predating the column, and bare entities
|
||||
* built in tests, behave exactly as before.
|
||||
*
|
||||
* <p>Single source of truth: the run report renders idle days from this
|
||||
* same method, so a report can never disagree with the decision it
|
||||
* describes.
|
||||
*/
|
||||
public static LocalDateTime anchor(SkillEntity skill) {
|
||||
if (skill.getLastActivityAt() != null) {
|
||||
return skill.getLastActivityAt();
|
||||
}
|
||||
if (skill.getCuratorSeenAt() != null) {
|
||||
return skill.getCuratorSeenAt();
|
||||
}
|
||||
return skill.getCreateTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether no sweep has observed this skill yet, so judging it now would
|
||||
* apply the idle thresholds to time it spent outside curation.
|
||||
*/
|
||||
public static boolean isUnobserved(SkillEntity skill) {
|
||||
return skill.getCuratorSeenAt() == null && skill.getLastActivityAt() == null;
|
||||
}
|
||||
|
||||
/** Stamp the observation anchor, starting the skill's idle clock now. */
|
||||
public void markObserved(SkillEntity skill, LocalDateTime now) {
|
||||
skillMapper.update(null, new LambdaUpdateWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getId, skill.getId())
|
||||
.set(SkillEntity::getCuratorSeenAt, now));
|
||||
skill.setCuratorSeenAt(now);
|
||||
}
|
||||
|
||||
/** Skills the curator must never touch (filtered out before the state machine). */
|
||||
public boolean isExempt(SkillEntity skill) {
|
||||
if (Boolean.TRUE.equals(skill.getBuiltin())) {
|
||||
@ -108,6 +143,12 @@ public class SkillLifecycleService {
|
||||
if (isExempt(skill)) {
|
||||
return LifecycleTransition.NONE;
|
||||
}
|
||||
// Never observed: the thresholds would be measured against time this
|
||||
// skill spent outside curation. Defer for a full cycle instead; the
|
||||
// sweep stamps the observation anchor so the next pass has a real one.
|
||||
if (isUnobserved(skill)) {
|
||||
return LifecycleTransition.NONE;
|
||||
}
|
||||
LocalDateTime anchor = anchor(skill);
|
||||
if (anchor == null) {
|
||||
return LifecycleTransition.NONE;
|
||||
@ -208,6 +249,107 @@ public class SkillLifecycleService {
|
||||
return skillMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a skill over to autonomous curation, or take it back.
|
||||
*
|
||||
* <p>Adoption deliberately does <em>not</em> buy a fresh idle window. An
|
||||
* operator hands over a skill knowing it is already idle, so the
|
||||
* observation anchor is set to creation time — the same anchor the skill
|
||||
* would have had if it had been curator-managed all along. Handing over a
|
||||
* library you stopped using therefore ages it out, which is the point of
|
||||
* handing it over. This is the deliberate difference from the implicit
|
||||
* first-sight seeding, where a skill arrives in scope through no decision
|
||||
* of the operator's and must not be judged on time spent outside it.
|
||||
*
|
||||
* <p>Releasing restores user ownership, so adoption is reversible.
|
||||
*
|
||||
* @param adopt {@code true} to hand over, {@code false} to take back
|
||||
* @throws MateClawException when the skill does not exist, or when it is
|
||||
* builtin (never curatable in the first place)
|
||||
*/
|
||||
public SkillEntity setAdopted(Long id, boolean adopt) {
|
||||
SkillEntity skill = skillMapper.selectById(id);
|
||||
if (skill == null) {
|
||||
throw new MateClawException("err.skill.not_found", 404, "Skill not found: " + id);
|
||||
}
|
||||
if (isExempt(skill)) {
|
||||
throw new MateClawException("err.skill.not_adoptable", 400,
|
||||
"Skill '" + skill.getName() + "' is exempt from curation (builtin, pinned, "
|
||||
+ "protected prefix, or a virtual mcp/acp skill)");
|
||||
}
|
||||
LambdaUpdateWrapper<SkillEntity> update = new LambdaUpdateWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getId, id)
|
||||
.set(SkillEntity::getOrigin,
|
||||
adopt ? SkillOrigin.AGENT.code() : SkillOrigin.USER.code());
|
||||
if (adopt) {
|
||||
// No fresh window: anchor where an always-managed skill would be.
|
||||
update.set(SkillEntity::getCuratorSeenAt, skill.getCreateTime());
|
||||
}
|
||||
skillMapper.update(null, update);
|
||||
recordAudit(adopt ? "ADOPT" : "RELEASE", skill,
|
||||
Map.of("origin", adopt ? SkillOrigin.AGENT.code() : SkillOrigin.USER.code()));
|
||||
return skillMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills outside autonomous curation, with the reason each one is out.
|
||||
*
|
||||
* <p>Without this a large library can look fully curated while most of it
|
||||
* is invisible to the sweep, and the only lever was widening the scope for
|
||||
* everything at once.
|
||||
*/
|
||||
public List<Map<String, Object>> listUnmanaged() {
|
||||
return roster(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills currently under autonomous curation — the set an operator can
|
||||
* hand back. Without it adoption would be one-way from the UI.
|
||||
*/
|
||||
public List<Map<String, Object>> listManaged() {
|
||||
return roster(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared roster projection. {@code managed} selects skills the curator may
|
||||
* touch ({@code origin} agent/routine) or the complement; exempt skills are
|
||||
* dropped from both sides because they are not curatable either way, so
|
||||
* offering adopt or release on them would be a lie.
|
||||
*/
|
||||
private List<Map<String, Object>> roster(boolean managed) {
|
||||
LambdaQueryWrapper<SkillEntity> q = new LambdaQueryWrapper<SkillEntity>()
|
||||
.eq(SkillEntity::getBuiltin, false);
|
||||
if (managed) {
|
||||
q.in(SkillEntity::getOrigin, SkillOrigin.curatorManagedCodes());
|
||||
} else {
|
||||
q.and(w -> w.isNull(SkillEntity::getOrigin)
|
||||
.or().eq(SkillEntity::getOrigin, SkillOrigin.USER.code()));
|
||||
}
|
||||
List<SkillEntity> rows = skillMapper.selectList(q);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (SkillEntity skill : rows) {
|
||||
if (isExempt(skill)) {
|
||||
continue;
|
||||
}
|
||||
LocalDateTime anchor = anchor(skill);
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
// Snowflake id as a string: 19 digits exceed JS Number precision.
|
||||
row.put("id", String.valueOf(skill.getId()));
|
||||
row.put("name", skill.getName());
|
||||
row.put("description", skill.getDescription());
|
||||
row.put("lifecycleState", skill.getLifecycleState());
|
||||
row.put("origin", skill.getOrigin());
|
||||
row.put("reason", managed
|
||||
? skill.getOrigin()
|
||||
: (skill.getOrigin() == null ? "predates-provenance" : "user-authored"));
|
||||
row.put("unobserved", isUnobserved(skill));
|
||||
row.put("daysIdle", anchor == null ? null : Duration.between(anchor, now).toDays());
|
||||
out.add(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the activity anchor of a skill to now and pull it back to
|
||||
* {@code active} if it had drifted to {@code stale}. Best-effort: a
|
||||
|
||||
@ -163,6 +163,22 @@ public class SkillEntity {
|
||||
*/
|
||||
private LocalDateTime lastActivityAt;
|
||||
|
||||
/**
|
||||
* When autonomous curation first saw this skill as a candidate.
|
||||
*
|
||||
* <p>Distinct from {@link #createTime}: a skill can exist for a long time
|
||||
* before it falls under curation at all — widening the curator scope pulls
|
||||
* a whole library in at once. Anchoring the idle clock on creation would
|
||||
* have such a skill enter curation already looking long-idle and archive it
|
||||
* on the very first sweep, so the moment curation began watching is
|
||||
* recorded separately.
|
||||
*
|
||||
* <p>{@code null} means no sweep has seen it yet; the next one stamps it
|
||||
* and defers judgement for a full cycle.
|
||||
*/
|
||||
@TableField(value = "curator_seen_at", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private LocalDateTime curatorSeenAt;
|
||||
|
||||
/** Wall-clock time the skill entered the archived state. */
|
||||
private LocalDateTime archivedAt;
|
||||
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
-- V178: Record when curation first saw a skill, separately from when it was created.
|
||||
--
|
||||
-- The idle clock anchored on create_time, which conflated two different
|
||||
-- moments: when a skill was written, and when it first fell under curation.
|
||||
-- For a skill that only becomes eligible later — widening the curator scope
|
||||
-- brings a whole library in at once — those differ by however long the skill
|
||||
-- existed unmanaged, so it entered curation already looking years idle and was
|
||||
-- archived on the very next sweep.
|
||||
--
|
||||
-- The backfill covers only rows already inside the default AGENT_CREATED scope,
|
||||
-- so their clocks continue exactly as before and upgrading changes nothing.
|
||||
-- Rows outside the scope stay NULL and get seeded the first time a sweep
|
||||
-- actually sees them.
|
||||
|
||||
ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS curator_seen_at TIMESTAMP;
|
||||
|
||||
UPDATE mate_skill SET curator_seen_at = create_time
|
||||
WHERE curator_seen_at IS NULL AND origin IN ('agent', 'routine');
|
||||
@ -0,0 +1,18 @@
|
||||
-- V178: Record when curation first saw a skill, separately from when it was created.
|
||||
--
|
||||
-- The idle clock anchored on create_time, which conflated two different
|
||||
-- moments: when a skill was written, and when it first fell under curation.
|
||||
-- For a skill that only becomes eligible later — widening the curator scope
|
||||
-- brings a whole library in at once — those differ by however long the skill
|
||||
-- existed unmanaged, so it entered curation already looking years idle and was
|
||||
-- archived on the very next sweep.
|
||||
--
|
||||
-- The backfill covers only rows already inside the default AGENT_CREATED scope,
|
||||
-- so their clocks continue exactly as before and upgrading changes nothing.
|
||||
-- Rows outside the scope stay NULL and get seeded the first time a sweep
|
||||
-- actually sees them.
|
||||
|
||||
ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS curator_seen_at TIMESTAMP(3);
|
||||
|
||||
UPDATE mate_skill SET curator_seen_at = create_time
|
||||
WHERE curator_seen_at IS NULL AND origin IN ('agent', 'routine');
|
||||
@ -0,0 +1,22 @@
|
||||
-- V178: Record when curation first saw a skill, separately from when it was created.
|
||||
--
|
||||
-- The idle clock anchored on create_time, which conflated two different
|
||||
-- moments: when a skill was written, and when it first fell under curation.
|
||||
-- For a skill that only becomes eligible later — widening the curator scope
|
||||
-- brings a whole library in at once — those differ by however long the skill
|
||||
-- existed unmanaged, so it entered curation already looking years idle and was
|
||||
-- archived on the very next sweep.
|
||||
--
|
||||
-- The backfill covers only rows already inside the default AGENT_CREATED scope,
|
||||
-- so their clocks continue exactly as before and upgrading changes nothing.
|
||||
-- Rows outside the scope stay NULL and get seeded the first time a sweep
|
||||
-- actually sees them.
|
||||
--
|
||||
-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use an INFORMATION_SCHEMA guard.
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_skill' AND COLUMN_NAME = 'curator_seen_at');
|
||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_skill ADD COLUMN curator_seen_at DATETIME DEFAULT NULL', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE mate_skill SET curator_seen_at = create_time
|
||||
WHERE curator_seen_at IS NULL AND origin IN ('agent', 'routine');
|
||||
@ -0,0 +1,158 @@
|
||||
package vip.mate.agent.graph.plan.node;
|
||||
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||
import vip.mate.planning.service.PlanningService;
|
||||
|
||||
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.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Pins the summary node's behaviour when the model returns no text.
|
||||
*
|
||||
* <p>An interleaved-thinking model can burn its whole turn on reasoning and
|
||||
* come back with an empty string. That empty summary used to flow through as
|
||||
* the run's terminal answer, the goal evaluator skipped on "terminalAnswer
|
||||
* empty", and a plan whose steps had all succeeded ended with a dangling
|
||||
* reasoning block and nothing to show for it. The step results are already in
|
||||
* hand at that point, so the node must answer from those.
|
||||
*/
|
||||
class PlanSummaryEmptyResultTest {
|
||||
|
||||
private ChatModel chatModel;
|
||||
private PlanningService planningService;
|
||||
private NodeStreamingChatHelper streamingHelper;
|
||||
private PlanSummaryNode node;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
chatModel = mock(ChatModel.class);
|
||||
planningService = mock(PlanningService.class);
|
||||
streamingHelper = mock(NodeStreamingChatHelper.class);
|
||||
node = new PlanSummaryNode(chatModel, planningService, streamingHelper);
|
||||
}
|
||||
|
||||
private static NodeStreamingChatHelper.StreamResult result(String text, String thinking) {
|
||||
return new NodeStreamingChatHelper.StreamResult(text, thinking, null, List.of(), false, 0, 0);
|
||||
}
|
||||
|
||||
private OverAllState state() {
|
||||
Map<String, Object> vals = new HashMap<>();
|
||||
vals.put(PlanStateKeys.PLAN_ID, 42L);
|
||||
vals.put(PlanStateKeys.GOAL, "对订单数据做质量体检");
|
||||
vals.put(PlanStateKeys.COMPLETED_RESULTS,
|
||||
List.of("第 1 步:主键唯一性,发现 3 条重复", "第 2 步:缺失率统计完成"));
|
||||
return new OverAllState(vals);
|
||||
}
|
||||
|
||||
private String summaryOf(Map<String, Object> out) {
|
||||
return String.valueOf(out.get(PlanStateKeys.FINAL_SUMMARY));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty summary text falls back to the step results")
|
||||
void emptyTextFallsBackToStepResults() throws Exception {
|
||||
// The exact shape that slipped through: empty string, not null. A null
|
||||
// tripped an NPE and reached the catch-block fallback by accident; "" did
|
||||
// not, so it was the only unhandled case.
|
||||
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
|
||||
.thenReturn(result("", "let me think about this for 36 seconds"));
|
||||
|
||||
Map<String, Object> out = node.apply(state());
|
||||
String summary = summaryOf(out);
|
||||
|
||||
assertFalse(summary.isBlank(), "an empty model summary must not become an empty answer");
|
||||
assertTrue(summary.contains("主键唯一性"), summary);
|
||||
assertTrue(summary.contains("缺失率统计"), summary);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("whitespace-only summary is treated the same as empty")
|
||||
void whitespaceOnlyTextFallsBack() throws Exception {
|
||||
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
|
||||
.thenReturn(result(" \n ", ""));
|
||||
|
||||
assertTrue(summaryOf(node.apply(state())).contains("主键唯一性"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty summary completes the plan — the steps did succeed")
|
||||
void emptySummaryStillCompletesThePlan() throws Exception {
|
||||
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
|
||||
.thenReturn(result("", "thinking"));
|
||||
|
||||
node.apply(state());
|
||||
|
||||
// Nothing failed: every step ran, only the summary text was missing.
|
||||
// Marking the plan failed would misreport a successful run.
|
||||
verify(planningService).completePlan(eq(42L), anyString());
|
||||
verify(planningService, never()).markPlanFailed(any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the fallback says the model produced nothing, not that the run failed")
|
||||
void emptySummaryNoteDistinguishesFromFailure() throws Exception {
|
||||
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
|
||||
.thenReturn(result("", "thinking"));
|
||||
|
||||
String summary = summaryOf(node.apply(state()));
|
||||
|
||||
assertTrue(summary.contains("未产出汇总正文"), summary);
|
||||
assertFalse(summary.contains("汇总失败"),
|
||||
"nothing failed here; calling it a failure misreports a successful run: " + summary);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null thinking does not blow up the node")
|
||||
void nullThinkingIsTolerated() throws Exception {
|
||||
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
|
||||
.thenReturn(result("", null));
|
||||
|
||||
assertTrue(summaryOf(node.apply(state())).contains("主键唯一性"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a real summary is passed through untouched")
|
||||
void realSummaryIsUnchanged() throws Exception {
|
||||
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
|
||||
.thenReturn(result("体检完成:共发现 5 类问题。", "thinking"));
|
||||
|
||||
Map<String, Object> out = node.apply(state());
|
||||
|
||||
assertEquals("体检完成:共发现 5 类问题。", summaryOf(out));
|
||||
verify(planningService).completePlan(eq(42L), eq("体检完成:共发现 5 类问题。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty summary with no step results still yields a readable answer")
|
||||
void emptySummaryWithNoStepResults() throws Exception {
|
||||
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
|
||||
.thenReturn(result("", "thinking"));
|
||||
Map<String, Object> vals = new HashMap<>();
|
||||
vals.put(PlanStateKeys.PLAN_ID, 42L);
|
||||
vals.put(PlanStateKeys.GOAL, "空计划");
|
||||
vals.put(PlanStateKeys.COMPLETED_RESULTS, List.of());
|
||||
|
||||
String summary = summaryOf(node.apply(new OverAllState(vals)));
|
||||
|
||||
assertFalse(summary.isBlank());
|
||||
assertTrue(summary.contains("没有已完成的步骤结果"), summary);
|
||||
}
|
||||
}
|
||||
@ -199,6 +199,54 @@ class SkillCuratorJobTest {
|
||||
assertEquals(1, cap.getValue().getApplied().stale());
|
||||
}
|
||||
|
||||
/** A candidate curation has never seen: no activity, no observation stamp. */
|
||||
private SkillEntity unobservedCandidate(long id, LocalDateTime createdAt) {
|
||||
SkillEntity s = candidate(id, "active", null);
|
||||
s.setCreateTime(createdAt);
|
||||
return s;
|
||||
}
|
||||
|
||||
@Test
|
||||
void unobservedCandidateIsSeededAndNotJudged() {
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false);
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(true);
|
||||
stubSweep(List.of(unobservedCandidate(1L, now.minusDays(900))));
|
||||
|
||||
job.run();
|
||||
|
||||
// Stamped so the next sweep has a real anchor, but never judged this time.
|
||||
verify(lifecycleService).markObserved(any(), any());
|
||||
verify(lifecycleService, never()).planTransition(any(), any());
|
||||
verify(lifecycleService, never()).apply(any(), any(), any());
|
||||
|
||||
ArgumentCaptor<SkillCuratorReport> cap = ArgumentCaptor.forClass(SkillCuratorReport.class);
|
||||
verify(reportStore).write(cap.capture());
|
||||
assertEquals(1, cap.getValue().getNewlyObserved());
|
||||
assertEquals(0, cap.getValue().getPlanned().archived());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dryRunDoesNotSeedButReachesTheSameVerdict() {
|
||||
// The preview is what an operator reads before widening the scope, so
|
||||
// it must not predict archives that a real run would defer — while
|
||||
// still writing nothing.
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false);
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(false);
|
||||
when(systemSettingService.getString(eq(SkillCuratorJob.LAST_OBSERVED_KEY), any()))
|
||||
.thenReturn(now.minusDays(2).toString());
|
||||
stubSweep(List.of(unobservedCandidate(1L, now.minusDays(900))));
|
||||
|
||||
job.run();
|
||||
|
||||
verify(lifecycleService, never()).markObserved(any(), any());
|
||||
verify(lifecycleService, never()).planTransition(any(), any());
|
||||
|
||||
ArgumentCaptor<SkillCuratorReport> cap = ArgumentCaptor.forClass(SkillCuratorReport.class);
|
||||
verify(reportStore).write(cap.capture());
|
||||
assertEquals(1, cap.getValue().getNewlyObserved());
|
||||
assertEquals(0, cap.getValue().getPlanned().archived());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcileReactivatesArchivedRowWhoseWorkspaceReturned() {
|
||||
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false);
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
package vip.mate.skill.lifecycle;
|
||||
import vip.mate.skill.model.SkillOrigin;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@ -6,6 +9,7 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
@ -21,6 +25,7 @@ import vip.mate.skill.workspace.SkillWorkspaceProperties;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@ -82,6 +87,125 @@ class SkillLifecycleServiceTest {
|
||||
return s;
|
||||
}
|
||||
|
||||
// ==================== adopt / release ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("adopting anchors to creation time — it does not buy a fresh window")
|
||||
void adoptDoesNotResetTheIdleClock() {
|
||||
// The operator hands over a skill knowing it is idle; granting it a new
|
||||
// 90-day lease would defeat the reason they handed it over.
|
||||
SkillEntity s = unobserved(now.minusDays(400));
|
||||
s.setOrigin(null);
|
||||
when(skillMapper.selectById(1L)).thenReturn(s);
|
||||
|
||||
service.setAdopted(1L, true);
|
||||
|
||||
ArgumentCaptor<LambdaUpdateWrapper<SkillEntity>> cap = updateCaptor();
|
||||
verify(skillMapper).update(eq(null), cap.capture());
|
||||
String sql = cap.getValue().getSqlSet();
|
||||
assertTrue(sql.contains("origin"), sql);
|
||||
assertTrue(sql.contains("curator_seen_at"), sql);
|
||||
|
||||
// With the anchor at creation time the skill ages immediately.
|
||||
s.setCuratorSeenAt(s.getCreateTime());
|
||||
assertEquals(LifecycleTransition.TO_ARCHIVED, service.planTransition(s, now));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("releasing hands ownership back and leaves the clock alone")
|
||||
void releaseRestoresUserOwnership() {
|
||||
SkillEntity s = skill("dynamic", "active", now.minusDays(10));
|
||||
s.setOrigin(SkillOrigin.AGENT.code());
|
||||
when(skillMapper.selectById(1L)).thenReturn(s);
|
||||
|
||||
service.setAdopted(1L, false);
|
||||
|
||||
ArgumentCaptor<LambdaUpdateWrapper<SkillEntity>> cap = updateCaptor();
|
||||
verify(skillMapper).update(eq(null), cap.capture());
|
||||
String sql = cap.getValue().getSqlSet();
|
||||
assertTrue(sql.contains("origin"), sql);
|
||||
assertFalse(sql.contains("curator_seen_at"), "release must not touch the clock: " + sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an exempt skill cannot be adopted")
|
||||
void exemptSkillIsNotAdoptable() {
|
||||
SkillEntity builtin = skill("builtin", "active", now.minusDays(10));
|
||||
builtin.setBuiltin(true);
|
||||
when(skillMapper.selectById(1L)).thenReturn(builtin);
|
||||
|
||||
assertThrows(MateClawException.class, () -> service.setAdopted(1L, true));
|
||||
verify(skillMapper, never()).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("adopting a missing skill is a 404, not a silent no-op")
|
||||
void adoptMissingSkillThrows() {
|
||||
when(skillMapper.selectById(404L)).thenReturn(null);
|
||||
assertThrows(MateClawException.class, () -> service.setAdopted(404L, true));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private ArgumentCaptor<LambdaUpdateWrapper<SkillEntity>> updateCaptor() {
|
||||
return ArgumentCaptor.forClass((Class<LambdaUpdateWrapper<SkillEntity>>) (Class<?>) LambdaUpdateWrapper.class);
|
||||
}
|
||||
|
||||
// ==================== observation anchor ====================
|
||||
|
||||
/** A skill curation has never seen: no activity, no observation stamp. */
|
||||
private SkillEntity unobserved(LocalDateTime createdAt) {
|
||||
SkillEntity s = skill("dynamic", "active", null);
|
||||
s.setCreateTime(createdAt);
|
||||
return s;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a never-observed skill is deferred however old it is")
|
||||
void unobservedSkillIsDeferred() {
|
||||
// Widening the curator scope pulls in skills created years ago. Judging
|
||||
// them on creation time would archive the whole batch on the first sweep.
|
||||
SkillEntity s = unobserved(now.minusDays(900));
|
||||
assertTrue(SkillLifecycleService.isUnobserved(s));
|
||||
assertEquals(LifecycleTransition.NONE, service.planTransition(s, now));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("once observed, the idle clock runs from the observation, not creation")
|
||||
void observationAnchorReplacesCreationTime() {
|
||||
SkillEntity s = unobserved(now.minusDays(900));
|
||||
s.setCuratorSeenAt(now.minusDays(2));
|
||||
|
||||
assertFalse(SkillLifecycleService.isUnobserved(s));
|
||||
assertEquals(now.minusDays(2), SkillLifecycleService.anchor(s));
|
||||
assertEquals(LifecycleTransition.NONE, service.planTransition(s, now));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an observed skill ages normally once the threshold passes")
|
||||
void observedSkillStillAges() {
|
||||
SkillEntity s = unobserved(now.minusDays(900));
|
||||
s.setCuratorSeenAt(now.minusDays(95));
|
||||
|
||||
assertEquals(LifecycleTransition.TO_ARCHIVED, service.planTransition(s, now));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("real activity outranks the observation stamp")
|
||||
void activityOutranksObservation() {
|
||||
SkillEntity s = skill("dynamic", "active", now.minusDays(1));
|
||||
s.setCuratorSeenAt(now.minusDays(400));
|
||||
|
||||
assertEquals(now.minusDays(1), SkillLifecycleService.anchor(s));
|
||||
assertEquals(LifecycleTransition.NONE, service.planTransition(s, now));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("creation time remains the fallback for rows predating the column")
|
||||
void creationTimeRemainsFallback() {
|
||||
SkillEntity s = skill("dynamic", "active", now.minusDays(95));
|
||||
assertEquals(now.minusDays(95), SkillLifecycleService.anchor(s));
|
||||
}
|
||||
|
||||
// ==================== planTransition ====================
|
||||
|
||||
@Test
|
||||
|
||||
@ -327,6 +327,17 @@ export const skillApi = {
|
||||
|
||||
// ---- Curator restore points ----
|
||||
/** List recent skill-library restore points (newest first). */
|
||||
/** Skills currently under autonomous curation — the set that can be released. */
|
||||
curatorManaged: () => http.get('/skills/curator/managed'),
|
||||
/** Skills outside autonomous curation, with the reason each one is out. */
|
||||
curatorUnmanaged: () => http.get('/skills/curator/unmanaged'),
|
||||
/**
|
||||
* Hand skills over to autonomous curation. Ids stay strings — 19-digit
|
||||
* snowflake ids lose precision through the JS Number type.
|
||||
*/
|
||||
curatorAdopt: (skillIds: string[]) => http.post('/skills/curator/adopt', skillIds),
|
||||
/** Take skills back from autonomous curation. */
|
||||
curatorRelease: (skillIds: string[]) => http.post('/skills/curator/release', skillIds),
|
||||
curatorSnapshots: () => http.get('/skills/curator/snapshots'),
|
||||
/** Capture a restore point on demand. */
|
||||
curatorSnapshotCapture: (reason?: string) =>
|
||||
|
||||
@ -4217,6 +4217,21 @@ export default {
|
||||
snapshotRestore: 'Roll back to this',
|
||||
snapshotRestoreConfirm: 'This rewrites every skill body and lifecycle state back to how they were at {time} ({n} skills), overwriting the current content. A restore point is captured first, so this is itself undoable. Continue?',
|
||||
snapshotRestoreSuccess: 'Restored {restored} skill(s); {missing} no longer present and skipped',
|
||||
unmanaged: 'Skills outside curation',
|
||||
unmanagedHint: 'These skills are not subject to automatic archival. Handing one over puts it on the idle clock — adoption does not grant a fresh window, so an already-idle skill may be archived on the next sweep.',
|
||||
managedList: 'Under curation',
|
||||
unmanagedList: 'Outside curation',
|
||||
noManaged: 'No skills under curation yet',
|
||||
release: 'Release',
|
||||
releaseSuccess: "Released '{name}'",
|
||||
unobserved: 'Clock not started yet',
|
||||
noUnmanaged: 'No unmanaged skills',
|
||||
unmanagedReasonLegacy: 'Predates the provenance field; authorship unknowable',
|
||||
unmanagedReasonUser: 'Created by a user',
|
||||
unmanagedDaysIdle: 'Idle {n} day(s)',
|
||||
adopt: 'Adopt',
|
||||
adoptConfirm: 'Hand \'{name}\' over to autonomous curation? This does not reset its idle clock — if it is already long idle, the next sweep may archive it.',
|
||||
adoptSuccess: 'Adopted \'{name}\'',
|
||||
consolidateCreate: 'new',
|
||||
consolidateEdit: 'edit',
|
||||
activateSuccess: 'Skill curator activated',
|
||||
|
||||
@ -4309,6 +4309,21 @@ export default {
|
||||
snapshotRestore: '回滚到此',
|
||||
snapshotRestoreConfirm: '将把所有技能的正文和生命周期状态回滚到 {time} 的状态(共 {n} 个技能),当前内容会被覆盖。回滚前会自动留一个还原点,所以此操作可以再撤销。确认继续?',
|
||||
snapshotRestoreSuccess: '已恢复 {restored} 个技能,{missing} 个已不存在而跳过',
|
||||
unmanaged: '未纳入治理的技能',
|
||||
unmanagedHint: '这些技能不受自动归档管理。移交后由 curator 按闲置时长治理——移交不会重置闲置时钟,已经长期闲置的技能可能在下一轮就被归档。',
|
||||
managedList: '已纳管',
|
||||
unmanagedList: '未纳管',
|
||||
noManaged: '还没有纳入治理的技能',
|
||||
release: '撤销移交',
|
||||
releaseSuccess: '已撤销「{name}」的移交',
|
||||
unobserved: '尚未开始计时',
|
||||
noUnmanaged: '没有未纳管的技能',
|
||||
unmanagedReasonLegacy: '早于溯源字段,作者不可考',
|
||||
unmanagedReasonUser: '用户创建',
|
||||
unmanagedDaysIdle: '已闲置 {n} 天',
|
||||
adopt: '移交治理',
|
||||
adoptConfirm: '确定把「{name}」移交给自治治理?移交不会重置闲置时钟,若它已长期闲置,下一轮扫描可能直接归档。',
|
||||
adoptSuccess: '已移交「{name}」',
|
||||
consolidateCreate: '新建',
|
||||
consolidateEdit: '更新',
|
||||
activateSuccess: '技能管家已激活',
|
||||
|
||||
@ -157,6 +157,64 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Skills outside curation -->
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
<h3 class="card-title">{{ t('skillCurator.unmanaged') }}</h3>
|
||||
<div class="curator-actions">
|
||||
<button class="btn-secondary" :disabled="busy" @click="loadUnmanaged">
|
||||
{{ t('common.refresh', 'Refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="curator-hint">{{ t('skillCurator.unmanagedHint') }}</p>
|
||||
|
||||
<h4 class="roster-heading">{{ t('skillCurator.managedList') }}</h4>
|
||||
<p v-if="managed.length === 0" class="empty-note">{{ t('skillCurator.noManaged') }}</p>
|
||||
<ul v-else class="routine-list">
|
||||
<li v-for="m in managed" :key="m.id" class="routine-item">
|
||||
<div class="routine-main">
|
||||
<p class="routine-text">{{ m.name }}</p>
|
||||
<div class="routine-meta">
|
||||
<span>{{ m.reason }}</span>
|
||||
<span v-if="m.unobserved">{{ t('skillCurator.unobserved') }}</span>
|
||||
<span v-else-if="m.daysIdle != null">
|
||||
{{ t('skillCurator.unmanagedDaysIdle', { n: m.daysIdle }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="routine-actions">
|
||||
<button class="btn-secondary" :disabled="busy" @click="release(m)">
|
||||
{{ t('skillCurator.release') }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="roster-heading">{{ t('skillCurator.unmanagedList') }}</h4>
|
||||
<p v-if="unmanaged.length === 0" class="empty-note">{{ t('skillCurator.noUnmanaged') }}</p>
|
||||
<ul v-else class="routine-list">
|
||||
<li v-for="u in unmanaged" :key="u.id" class="routine-item">
|
||||
<div class="routine-main">
|
||||
<p class="routine-text">{{ u.name }}</p>
|
||||
<div class="routine-meta">
|
||||
<span>{{ u.reason === 'predates-provenance'
|
||||
? t('skillCurator.unmanagedReasonLegacy')
|
||||
: t('skillCurator.unmanagedReasonUser') }}</span>
|
||||
<span v-if="u.daysIdle != null">
|
||||
{{ t('skillCurator.unmanagedDaysIdle', { n: u.daysIdle }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="routine-actions">
|
||||
<button class="btn-secondary" :disabled="busy" @click="adopt(u)">
|
||||
{{ t('skillCurator.adopt') }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Restore points -->
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
@ -299,6 +357,19 @@ interface SkillSnapshot {
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
interface UnmanagedSkill {
|
||||
/** Snowflake id kept as a string end-to-end — 19 digits exceed Number precision. */
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
lifecycleState?: string | null
|
||||
origin?: string | null
|
||||
reason: string
|
||||
/** No sweep has observed it yet, so its idle clock has not started. */
|
||||
unobserved?: boolean
|
||||
daysIdle?: number | null
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
@ -307,6 +378,8 @@ const routines = ref<RoutineCandidate[]>([])
|
||||
const gates = ref<RoutineGates>({ minOccurrences: 3, minDistinctDays: 3, enabled: true })
|
||||
const routineFilter = ref<string>('observing')
|
||||
const snapshots = ref<SkillSnapshot[]>([])
|
||||
const unmanaged = ref<UnmanagedSkill[]>([])
|
||||
const managed = ref<UnmanagedSkill[]>([])
|
||||
|
||||
const routineFilters = [
|
||||
{ value: 'observing', label: 'skillCurator.routineFilterObserving' },
|
||||
@ -331,7 +404,7 @@ async function load() {
|
||||
try {
|
||||
const res: any = await skillApi.curatorStatus()
|
||||
status.value = res.data as CuratorStatus
|
||||
await Promise.all([loadReports(), loadRoutines(), loadSnapshots()])
|
||||
await Promise.all([loadReports(), loadRoutines(), loadSnapshots(), loadUnmanaged()])
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || t('skillCurator.loadFailed')
|
||||
} finally {
|
||||
@ -508,6 +581,59 @@ async function reopenRoutine(r: RoutineCandidate) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Skills outside curation ====================
|
||||
|
||||
async function loadUnmanaged() {
|
||||
try {
|
||||
const [un, mg]: any[] = await Promise.all([
|
||||
skillApi.curatorUnmanaged(),
|
||||
skillApi.curatorManaged(),
|
||||
])
|
||||
unmanaged.value = Array.isArray(un.data) ? un.data : []
|
||||
managed.value = Array.isArray(mg.data) ? mg.data : []
|
||||
} catch {
|
||||
unmanaged.value = []
|
||||
managed.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function release(u: UnmanagedSkill) {
|
||||
busy.value = true
|
||||
try {
|
||||
await skillApi.curatorRelease([u.id])
|
||||
mcToast.success(t('skillCurator.releaseSuccess', { name: u.name }))
|
||||
await loadUnmanaged()
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.message || t('skillCurator.actionFailed'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function adopt(u: UnmanagedSkill) {
|
||||
// Adoption does not grant a fresh idle window — an already-idle skill can
|
||||
// age out on the very next sweep — so state that before acting on it.
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('skillCurator.adoptConfirm', { name: u.name }),
|
||||
t('skillCurator.adopt'),
|
||||
{ type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
await skillApi.curatorAdopt([u.id])
|
||||
mcToast.success(t('skillCurator.adoptSuccess', { name: u.name }))
|
||||
await loadUnmanaged()
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.message || t('skillCurator.actionFailed'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Restore points ====================
|
||||
|
||||
async function loadSnapshots() {
|
||||
@ -604,6 +730,13 @@ onMounted(load)
|
||||
.kv-row code { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; }
|
||||
|
||||
.empty-note { margin: 0; font-size: 13px; color: var(--mc-text-tertiary); }
|
||||
.roster-heading {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
.roster-heading:first-of-type { margin-top: 8px; }
|
||||
.report-list { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.report-item { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; padding: 5px 10px; border-radius: 8px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; }
|
||||
.report-item:hover { border-color: var(--mc-text-tertiary); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user