mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
Compare commits
77 Commits
release/v1
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c9147b437 | |||
| e803e77bfe | |||
| 8b57691edf | |||
| fd8276e4aa | |||
| 4737aa10e2 | |||
| d759e74cca | |||
| 010a081d56 | |||
| f550bb2cca | |||
| 551a423322 | |||
| 6b9a7f36fd | |||
| 70f03354a2 | |||
| 4f9370ae7a | |||
| c67336c376 | |||
| bb16f19ea5 | |||
| 2c7ab6a187 | |||
| 9311b41dd3 | |||
| 996e70a64d | |||
| 68b773ad61 | |||
| 18324f6e80 | |||
| 8c5c0a25a6 | |||
| fdfba4eaa7 | |||
| 215804cf2c | |||
| ca5b04532d | |||
| 01232a66da | |||
| 3a5f6e1634 | |||
| f1091676f8 | |||
| eb4762a2b8 | |||
| 4d2028fbef | |||
| d4c5e808c7 | |||
| 5299e18cd8 | |||
| 6a3439a49c | |||
| ba0b5a1fe0 | |||
| bfdb395731 | |||
| d1959cbe80 | |||
| a37bc7333e | |||
| 1a02971f88 | |||
| 5836c2ce5c | |||
| dae01f9c06 | |||
| e44164f5bd | |||
| 96c1d7baae | |||
| 8e88ae789f | |||
| e985151da9 | |||
| b393b7072c | |||
| 50ba4a64cb | |||
| 15d912ef34 | |||
| 2a4315cfe4 | |||
| b09f459aea | |||
| e2672c56d6 | |||
| b71a88a471 | |||
| e218d0f100 | |||
| b096e8ba2c | |||
| f6f92a8a6e | |||
| c03de9878d | |||
| ca584bda3f | |||
| cf3c99233b | |||
| 2bc813be13 | |||
| 9cfdfc9de8 | |||
| 772a494223 | |||
| 5608abdf5a | |||
| cf098a1d4f | |||
| a8e3633174 | |||
| ad016e4410 | |||
| 7a328679da | |||
| 580d5f5501 | |||
| 6c2039f5c9 | |||
| 6f8056d236 | |||
| 98365f3a55 | |||
| 8c9d96e065 | |||
| e9856c8f65 | |||
| 0af38a29c6 | |||
| b889934bba | |||
| 8cd884027f | |||
| e4be9667ec | |||
| 1b4643089c | |||
| 8c6b593a54 | |||
| 687e5655bc | |||
| e8d12db871 |
7
.gitignore
vendored
7
.gitignore
vendored
@ -48,7 +48,12 @@ codegraph.json
|
||||
/adapters/
|
||||
/knowledge/
|
||||
/memory/
|
||||
/scripts/
|
||||
# 注:/scripts/ **不**忽略。它是作者维护的工具目录(模型导出、侧车、部署校验),
|
||||
# 不是运行期产物:deploy/systemd/embed-sidecar.service 直接引用
|
||||
# scripts/embed_sidecar.py,忽略它会让那份 unit 在别人的机器上指向不存在的文件。
|
||||
# 只忽略其中的缓存。
|
||||
/scripts/__pycache__/
|
||||
__pycache__/
|
||||
terminal_locked_log.txt
|
||||
|
||||
dist/
|
||||
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
62
README.md
62
README.md
@ -12,6 +12,8 @@
|
||||
homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
||||
```
|
||||
|
||||
**v1.1.1 起媒体贯通插件边界**:插件与模型都能读写记忆里的图片/音频(`InsertWithMedia`、`InjectInputMedia`),媒体以 `[<mime> <短digest>] <描述>` 标记存在于纯文本记忆中——描述是可检索的语义记忆,digest 是回到字节的钥匙。
|
||||
|
||||
**v1.0.0 起外部插件是独立子进程**:经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)与内核通信。插件崩溃不影响内核且自动重启,换 `plugin.bin` 即生效的真热重载。
|
||||
|
||||
## 设计要点
|
||||
@ -188,14 +190,43 @@ internal/
|
||||
├── config/ SQLite 配置中心
|
||||
├── events/ 事件总线
|
||||
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
```
|
||||
|
||||
## 项目状态
|
||||
|
||||
**v1.1.0** — 记忆系统支持二进制多媒体节点。此前四层记忆(L0 活跃上下文 / L1 文本 / L2 文档 / L3 图库)全部只存文字,图片音频经视觉模型转成描述后原始字节即丢弃,"那张紫蓝红三色带图"再也取不回来。本版新增内容寻址媒体存储(CAS,`internal/memory/media`):元数据进 SQLite、blob 按 sha256 落盘去重,L0/L2/L3 各层只记 digest 并通过 `media_refs` 维护引用计数,容量上限由后台 GC 真正兑现(被引用的内容即便超限也永不删除)。**描述文本才是持久语义记忆,blob 只是缓存**——描述随记忆各层一直留存并可检索,原始字节可被容量 GC 淘汰,因此几个月后仍能从图库句子反查到那张图(若尚在则逐字节取回)。描述由后台循环经视觉源生成(默认关闭,开启后每 30s 最多 4 条,不与对话抢配额),放在对话路径上会给每张图的回复加十几秒而收益为零——那一轮模型本来就直接看着图。同时修五个缺陷:`core.New` 漏接 `rc.SetMediaStore` 致 L0→L2 引用转移在生产静默失效;三元组全被实体名校验拒绝时仍释放引用并删除文档(数据丢失,已反向验证);媒体入图库曾依赖 NLP 提取器碰巧提出合规三元组而时好时坏,改为按媒体标记确定性产出;L3 媒体检索一度没有任何调用方(能存进去、agent 拿不出来);`remotedevice` 网关与 `agentcli` 终端各一处数据竞争。配套 `-tags medialive` 自动触发链实测:只注入一个图片事件,落盘/描述/归档/图库绑定/GC 保护/二轮召回七个阶段全由生产代码自行触发,真实视觉模型下 agent 在不给图的第二轮准确答出三条色带的颜色与近似 hex。插件 ABI 未变(`SDKCompatibleVersion` 仍为 1.0.0),存量 `plugin.bin` 无需重编。
|
||||
**v1.2.0** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。
|
||||
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
- **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 provider SPI
|
||||
(`pkg/embedding`:`Modality` / `Input{Data,MIME}` / `Info{Dimension,Fingerprint,Modalities}`
|
||||
+ 名字注册表),实现在 `providers/*`。默认 **Chinese-CLIP ViT-B/16** —— text 与 image
|
||||
落在**同一空间**(512 维、指纹 `cd2a495cf990`、Apache-2.0、独立实测常驻约 1.15GB);
|
||||
`qwen3vl` 保留(2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
|
||||
文本检索仍由既有词向量 / TF-IDF 兜底:CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**,
|
||||
这是已知并写进文档的代价。
|
||||
- **媒体是图数据库的一等节点与边**:**彻底删除**「用文本描述式索引图片」这套将就机制,
|
||||
以及 `media_refs` 与媒体引用计数。记忆块遵循单层不变量——Context → Document → Graph
|
||||
是块的**迁移**,不是复制、也不靠引用保活。
|
||||
- **数据面全部走共享内存**(工具调用帧 / Cleaner / 输入输出通道 / 媒体块 / 文档与知识正文),
|
||||
RPC 只传偏移描述符;**RPC 协议升到 2**,fd3 布局改变,**不支持滚动升级**——
|
||||
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `hmapdev` 重编。
|
||||
- 注入可声明 `InjectOptions{NoMemory, ContextPolicy}`(**默认仍记入记忆、默认不裁剪**);
|
||||
裁剪必须显式声明,且先经插件注册的 `Cleaner`。SDK 1.2.0 相对 1.1.0 **纯追加**。
|
||||
- **发行包默认启用** ONNX 向量空间,并把模型(754MB)与 ONNX Runtime(24MB)随
|
||||
server/full 包发布;`homed` 放弃 Windows 原生支持改走 WSL2;jieba 词库内嵌进二进制。
|
||||
- 修掉三个**安装链静默失败**:`initconfig` 因 `CGO_ENABLED=0` 是空操作(打印凭据却一个字节
|
||||
没写)、全新安装被误判「已有配置」而整体跳过默认值播种(装完 0 插件)、deb 的 `postinst`
|
||||
查错 unit 路径导致 `enable` 从未执行。
|
||||
- 自本版起以 **AGPL-3.0-only** 发布(含网络条款;插件静态链接 SDK 故须同许可,见「许可」)。
|
||||
|
||||
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**:
|
||||
> 「媒体以 `[<mime> <短digest>] <描述>` 标记参与检索」(描述式索引)与「媒体引用计数式 GC」。
|
||||
|
||||
**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用(媒体永久算「被引用」,GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string)、**`memory_commit` 的 `sentence_text` 从未暴露给模型**(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**(`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。
|
||||
|
||||
**v1.1.0** — 记忆系统支持**二进制多媒体节点**。内容寻址媒体存储(CAS + SQLite 元数据 + 磁盘 blob,`Get` always 重校 digest),贯通 L0(上下文事件)/L2(文档)/L3(图谱句子)三层,引用计数式 GC(有引用者绝不删)。视觉模型生成的描述文本是持久语义记忆,blob 只是可被容量 GC 淘汰的缓存。
|
||||
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版工具链重编;该工具链当时名为 `plugindev`,**现名 `hmapdev`**)。外部插件需重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
|
||||
**v0.9.0** — C ABI v2:外部插件 Stage 回调支持写回(`invoke_stage` 增加 result 输出,插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核),ABI 版本随内核 minor 对齐(v0.9.x → ABIVersion=2,`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题。配套 SDK 提供增强版 sanitizer 示例(坏 UTF-8/U+FFFD/ANSI 转义全链路清洗)。**该 ABI 已随 v1.0.0 退场。**
|
||||
|
||||
@ -220,7 +251,7 @@ internal/
|
||||
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
||||
|
||||
- Linux:`.deb`(amd64/arm64)、`.rpm`(x86_64)、`.tar.gz`
|
||||
- Windows:`HomeAgent_v1.1.0_{Full,Server,Client}_win64.exe`(NSIS 安装向导)
|
||||
- Windows:`HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe`(NSIS 安装向导,含 AGPL 许可页)。自 v1.2.0 起因 `homed` 不再支持 Windows 原生(依赖 fd 继承与共享内存段内偏移解引用),安装器改为引导到 **WSL2**,并把 Linux 包送进发行版里按 Linux 方式安装。
|
||||
- 免安装:`homeagent-bin-<os>_<arch>.tar.gz`(含 homed/waiter/initconfig)
|
||||
- 校验:`SHA256SUMS`
|
||||
|
||||
@ -235,3 +266,26 @@ make install # 安装到系统
|
||||
```
|
||||
|
||||
依赖:Go 1.25+, CGo (go-sqlite3), Linux/Windows。
|
||||
|
||||
## 许可
|
||||
|
||||
本项目以 **GNU Affero 通用公共许可证第 3 版(AGPL-3.0-only)** 发布,全文见 [LICENSE](LICENSE)。
|
||||
|
||||
它是 GPL 家族里**传染性最强**的一档:不仅分发时须提供完整对应源码,
|
||||
**通过网络提供服务时也要向使用者提供源码**(§13 Remote Network Interaction)。
|
||||
即:任何人把改过的 HomeAgent 对外提供网络服务,都必须让该服务的使用者拿到改动后的源码。
|
||||
|
||||
插件与本项目通过公开 SDK **静态链接**(SDK 源码会进入插件二进制),因此插件是本项目的
|
||||
衍生作品,需以相同许可发布;子进程隔离不改变这一点,因为被链接的是 SDK 代码本身。
|
||||
|
||||
### 随包分发的第三方组件
|
||||
|
||||
| 组件 | 许可 | 位置 |
|
||||
|---|---|---|
|
||||
| Chinese-CLIP ViT-B/16(ONNX 产物) | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
|
||||
| ONNX Runtime(`libonnxruntime.so`) | MIT | `/usr/lib/homeagent/onnxruntime/` |
|
||||
| jieba 词库(内嵌进二进制) | MIT | 源码 `internal/memory/jiebadict/` |
|
||||
| Go 依赖(go-sqlite3、gojieba、bubbletea 等) | MIT / BSD-3 / Apache-2.0 | 均为宽松许可,与 AGPL-3.0 兼容 |
|
||||
|
||||
这些组件**保持各自原有许可**,不在本项目的 AGPL 授权范围内;发行包把它们的许可全文放在
|
||||
`/usr/share/doc/homeagent/licenses/`,并在 dep/rpm 元数据里声明本包许可为 `AGPL-3.0-only`。
|
||||
|
||||
94
README_EN.md
94
README_EN.md
@ -12,6 +12,11 @@ Combined with a **three-layer memory architecture** (Context → Document → Gr
|
||||
homed (kernel, zero IO) ← PluginSDK → plugins (all IO capabilities)
|
||||
```
|
||||
|
||||
**Since v1.1.1 media reaches the plugin boundary**: plugins and the model can both read and
|
||||
write images/audio in memory (`InsertWithMedia`, `InjectInputMedia`). Media lives in plain-text
|
||||
memory as a `[<mime> <short digest>] <description>` marker — the description is the searchable
|
||||
semantic memory, the digest is the key back to the bytes.
|
||||
|
||||
**Since v1.0.0 external plugins are independent subprocesses**, communicating with the kernel over
|
||||
stdio JSON-RPC (control plane) + a shared memory segment (data plane) + an event ring (notification
|
||||
plane). A plugin crash cannot take down the kernel and it restarts automatically; swapping
|
||||
@ -174,14 +179,68 @@ internal/
|
||||
├── config/ SQLite config center
|
||||
├── events/ Event bus
|
||||
└── internal/lua/adapters/ 8 LLM protocol adapter scripts
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `plugindev` toolchain, refer to Go and Lua examples in `example/`
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/`
|
||||
```
|
||||
|
||||
## Project Status
|
||||
|
||||
**v1.1.0** — Binary/multimedia nodes in the memory system. All four tiers (L0 active context / L1 text / L2 documents / L3 graph) previously stored text only: an image or audio clip was turned into a description by a vision model and the original bytes were dropped, so "that purple-blue-red banded image" could never be retrieved again. This release adds a content-addressed media store (CAS, `internal/memory/media`): metadata in SQLite, blobs deduplicated on disk by sha256, with every tier holding only digests and reference counts maintained through `media_refs`, so the capacity cap is finally enforced by a background GC (referenced content is never deleted, even over the limit). **The description text is the durable semantic memory; the blob is only a cache** — descriptions persist across all tiers and stay searchable while raw bytes may be evicted, so months later a graph sentence still resolves back to that image (byte-for-byte if it survives). Descriptions are generated by a background loop through a vision source (off by default; at most 4 items per 30s when enabled, so it never competes with conversations for quota) — doing it inline would add tens of seconds to every image reply for no gain, since the model is looking at the image in that turn anyway. Five defects fixed as well: `core.New` never called `rc.SetMediaStore`, silently disabling L0→L2 reference transfer in production; references were released and the document deleted even when every triple was rejected by entity-name validation (data loss, reverse-verified); media entering the graph depended on the NLP extractor happening to produce valid triples and was therefore intermittent, now replaced by deterministic triples derived from media markers; L3 media lookup had no callers at all (stored fine, unreachable by the agent); and one data race each in the `remotedevice` gateway and the `agentcli` terminal. Ships with a `-tags medialive` auto-trigger integration test: a single injected image event drives all seven stages — CAS write, description, archival, graph binding, GC protection, second-turn recall — entirely through production code paths, and with a real vision model the agent names all three band colours and their approximate hex values in a second turn that includes no image. Plugin ABI unchanged (`SDKCompatibleVersion` stays 1.0.0); existing `plugin.bin` files need no rebuild.
|
||||
**v1.2.0** — unified multimodal vector space, media promoted to first-class graph memory, and the whole data plane moved into shared memory.
|
||||
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
- **Model-neutral unified embedding space**: the kernel no longer adapts to any specific model.
|
||||
It exposes only a public provider SPI (`pkg/embedding`: `Modality` / `Input{Data,MIME}` /
|
||||
`Info{Dimension,Fingerprint,Modalities}` + a name registry), with implementations under
|
||||
`providers/*`. Default: **Chinese-CLIP ViT-B/16** — text and image land in the **same space**
|
||||
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0, ~1.15GB RSS measured standalone);
|
||||
`qwen3vl` is kept (2048-dim, ~9.4GB) for machines with headroom or future video. Text search
|
||||
still falls back to the existing word-vector / TF-IDF path — a CLIP dual tower's pure-text
|
||||
semantics are **weaker than an MLLM-style embedder**, a cost documented rather than hidden.
|
||||
- **Media are first-class nodes and edges in the graph DB**: the "index images via generated
|
||||
text descriptions" stopgap, `media_refs` and media reference counting are **removed**.
|
||||
Memory blocks follow a single-layer invariant — Context → Document → Graph is a **migration**,
|
||||
not a copy, and not kept alive by references.
|
||||
- **The entire data plane goes through shared memory** (tool-call frames, Cleaners, input/output
|
||||
lanes, media blocks, document and knowledge bodies); RPC carries only offset descriptors.
|
||||
**RPC protocol is now 2**: the fd3 layout changed and there is **no rolling upgrade** —
|
||||
kernel and all plugins must be rebuilt and installed together.
|
||||
- Injections can declare `InjectOptions{NoMemory, ContextPolicy}` (**defaults: still recorded,
|
||||
not pruned**); pruning must be requested explicitly and goes through the plugin's registered
|
||||
`Cleaner`. SDK 1.2.0 is **purely additive** over 1.1.0.
|
||||
- **Release packages enable the ONNX space by default** and bundle the model (754MB) plus
|
||||
ONNX Runtime (24MB) in the server/full packages; `homed` drops native Windows support in
|
||||
favour of WSL2; the jieba dictionary is embedded in the binary.
|
||||
- Fixed three **silent install-chain failures**: `initconfig` was a no-op (`CGO_ENABLED=0` stub)
|
||||
that printed credentials without writing any, fresh installs were misdetected as "already
|
||||
configured" so default seeding was skipped entirely (0 plugins installed), and the deb
|
||||
`postinst` looked for the unit in the wrong path so `enable` never ran.
|
||||
- Licensed **AGPL-3.0-only** from this version on (network clause included; statically linked
|
||||
plugins must match — see License).
|
||||
|
||||
> The historical entries below are kept verbatim to show the evolution; two mechanisms in them
|
||||
> were **removed in v1.2.0**: text-description-based media indexing, and reference-counted media GC.
|
||||
|
||||
**v1.1.1** — Multimodal reaches the **plugin boundary**. v1.1.0 gave the memory system binary
|
||||
multimedia nodes, but that path was open only to the kernel itself; this release opens it to
|
||||
plugins and the model. The public SDK gains media fields and three media injection methods
|
||||
(paired with [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),
|
||||
shared by the whole 1.1.x line), and the kernel implements the four matching RPCs. The bridge
|
||||
layer had been **silently dropping fields**: `Confidence`/types/`SentenceText` handed in by a
|
||||
plugin were discarded, `Doc` kept only three fields, and `Remove` never released references
|
||||
(media stayed "referenced" forever, so GC could never reclaim it). `processTextInput` and
|
||||
`processMediaInput` were unified into a single `processInput`, which finally gives the media
|
||||
path the dedup, `no_memory`, channel `Cleaner`, interrupt semantics and correct `EventRawInput`
|
||||
it had always lacked. Three real defects fixed: **user-sent images never appeared in the WebUI
|
||||
chat log** (the media path published a map while the subscriber asserted a string),
|
||||
**`memory_commit`'s `sentence_text` had never been exposed to the model** (though it is the
|
||||
mandatory link in the media binding chain), and **two data races in `PluginSDK`** (11 reported
|
||||
by `-race`; in production this showed up as sporadic nil-dereference crashes during plugin reload).
|
||||
|
||||
**v1.1.0** — Memory system supports **binary multimedia nodes**. Content-addressed media store
|
||||
(CAS + SQLite metadata + on-disk blobs, `Get` always re-verifies the digest) wired through L0
|
||||
(context events) / L2 (documents) / L3 (graph sentences), with reference-counted GC (referenced
|
||||
items are never deleted). The description text produced by the vision model is the durable
|
||||
semantic memory; the blob is only a cache that capacity GC may evict.
|
||||
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new toolchain — called `plugindev` back then, **now `hmapdev`** — though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
|
||||
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**
|
||||
|
||||
@ -206,7 +265,7 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
|
||||
|
||||
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
|
||||
- Windows: `HomeAgent_v1.1.0_{Full,Server,Client}_win64.exe` (NSIS installer)
|
||||
- Windows: `HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe` (NSIS installer, includes the AGPL license page). Since v1.2.0 `homed` no longer supports native Windows (it relies on fd inheritance and in-segment offset dereferencing), so the installer bootstraps **WSL2** and installs the Linux packages inside the distribution the same way a Linux host would.
|
||||
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
|
||||
- Verification: `SHA256SUMS`
|
||||
|
||||
@ -221,3 +280,30 @@ make install # Install to system
|
||||
```
|
||||
|
||||
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux/Windows.
|
||||
|
||||
## License
|
||||
|
||||
This project is released under the **GNU Affero General Public License, version 3
|
||||
(AGPL-3.0-only)** — see [LICENSE](LICENSE).
|
||||
|
||||
This is the strongest copyleft in the GPL family: besides shipping the complete corresponding
|
||||
source when you distribute the software, **you must also offer the source to users who interact
|
||||
with it over a network** (§13, Remote Network Interaction). Anyone running a modified HomeAgent
|
||||
as a network service therefore has to make the modified source available to that service's users.
|
||||
|
||||
Plugins are **statically linked** against this project through the public SDK (the SDK source
|
||||
ends up inside the plugin binary), so plugins are derivative works and must be released under
|
||||
the same license. Process isolation does not change this — what is linked is the SDK code itself.
|
||||
|
||||
### Third-party components shipped with the packages
|
||||
|
||||
| Component | License | Location |
|
||||
|---|---|---|
|
||||
| Chinese-CLIP ViT-B/16 (ONNX artifacts) | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
|
||||
| ONNX Runtime (`libonnxruntime.so`) | MIT | `/usr/lib/homeagent/onnxruntime/` |
|
||||
| jieba dictionary (embedded in the binary) | MIT | `internal/memory/jiebadict/` |
|
||||
| Go dependencies (go-sqlite3, gojieba, bubbletea, …) | MIT / BSD-3 / Apache-2.0 | permissive, AGPL-3.0-compatible |
|
||||
|
||||
These components keep their own licenses and are not relicensed by this project. Full texts are
|
||||
shipped in `/usr/share/doc/homeagent/licenses/`, and the package metadata declares this package
|
||||
as `AGPL-3.0-only`.
|
||||
|
||||
@ -90,8 +90,8 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
RelevanceContext — In-memory events[] + JSON persistence
|
||||
Append: Each input, CleanTemplateText → three-branch vector(textForVector)
|
||||
agent→Response, user→Input, cold_storage→Input+Response
|
||||
StaticEmbedder pretrained word embedding / TF-IDF fallback
|
||||
Prune: StaticEmbedder CosineSimilarity, keep topK + last 10
|
||||
Vector layers: unified multimodal space (primary, with fingerprint) → StaticEmbedder word embedding → TF-IDF (fallback)
|
||||
Prune: DenseCosine (compared only within the same fingerprint) → StaticEmbedder CosineSimilarity fallback; keep topK + last 10
|
||||
├── Keep → timeline → chronologically sorted → system prompt
|
||||
└── Low score → Document layer archive (original timestamp)
|
||||
Save: 5s debounce write to disk
|
||||
@ -99,7 +99,7 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
↓ Prune archive ↑ LLM active recall
|
||||
|
||||
② Document (File Memory)
|
||||
DocStore — JSON files + shared StaticEmbedder vector space with Context (fallback: TF-IDF InvertedIndex)
|
||||
DocStore — JSON files + dense vectors (unified multimodal space; dense_fp must match the current space fingerprint or the doc is recomputed; fallback: StaticEmbedder / TF-IDF InvertedIndex)
|
||||
Write: Prune archive / doc_commit / Graph snapshot (syncGraphToDocs)
|
||||
Read:
|
||||
├── Auto-inject: Query(input, top3) → similarity summary under same vector space → [Related Memory Docs] → system prompt (read-only)
|
||||
@ -132,11 +132,22 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
→ triples → GraphDB.Commit
|
||||
```
|
||||
|
||||
### Vectorization: Pretrained Word Embedding + TF-IDF Fallback
|
||||
### Vectorization: Unified Multimodal Space (primary) → Word Embedding → TF-IDF (fallback)
|
||||
|
||||
All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedder.go`):
|
||||
Vectorization degrades through three layers by availability; **each missing layer reports an explicit
|
||||
error and never pretends to succeed**:
|
||||
|
||||
**Primary Strategy — Pretrained Word Embedding (aligned 300d)**
|
||||
**① Unified multimodal space (primary path, since v1.2.0)**
|
||||
Text and images share **one model, one dimension, one fingerprint** (default `chineseclip`: 512d,
|
||||
Apache-2.0, Chinese-native; `qwen3vl` or an external `http` provider are alternatives).
|
||||
Providers register through the public `pkg/embedding` SPI — **the kernel hardcodes no model**.
|
||||
Vectors persist together with their fingerprint (`dense_fp` / `vec_model`); any mismatch with the
|
||||
current fingerprint triggers recomputation, and only blocks with the **same fingerprint and the same
|
||||
dimension** participate in fusion (mixing coordinate systems yields a direction resembling neither).
|
||||
|
||||
**② Word embedding (text fallback)** — `StaticEmbedder` (`internal/memory/static_embedder.go`):
|
||||
|
||||
**Model sources** (aligned 300d)
|
||||
- Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English
|
||||
- Configured via `core.agent.embedding_model_path` (comma-separated multi-model)
|
||||
- Path containing `numberbatch` → auto-download ConceptNet; `cc.zh.` → fastText Chinese; `cc.en.` → fastText English
|
||||
@ -196,28 +207,32 @@ single typo silently breaks reference binding with no error anywhere in the chai
|
||||
also gained `sentence_text`: media references hang off a sentence, so with no sentence there is
|
||||
nowhere to attach them.
|
||||
|
||||
### Media Memory (since v1.1.0)
|
||||
### Media Memory (since v1.2.0: first-class memory blocks)
|
||||
|
||||
`internal/memory/media/` — `Store`, content-addressed (CAS)
|
||||
Media is not attached content but a **first-class memory node**: `internal/memory/media/` is a
|
||||
content-addressed store (CAS), and graph `block` nodes carry its digest plus its own vector, while
|
||||
structural edges (e.g. `sentence --contains--> block`) express ownership.
|
||||
|
||||
| Concern | Approach | Why |
|
||||
|---|---|---|
|
||||
| Addressing | sha256 digest; metadata in SQLite, blobs on disk | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
|
||||
| Addressing | sha256 digest; metadata in SQLite, blobs on disk (`blobs/<first2>/<rest>`, two-level fanout) | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
|
||||
| Integrity | Every `Get` re-verifies the digest | Silently returning corrupt data on disk damage is far worse than an error |
|
||||
| Write atomicity | `.tmp` + rename | A half-written file taken as complete content would permanently poison that digest |
|
||||
| References | `owner_kind/owner_id/digest` composite primary key, `AddRef` idempotent | Three owner kinds: `context` (context events), `document`, `graph_sentence` |
|
||||
| GC | Two-stage with `minAge`, **referenced items are never deleted** | Description text stays in the text layers while blobs may be evicted — semantic memory and byte cache are decoupled |
|
||||
| Retrieval | Blocks carry **their own multimodal vector and fingerprint** and are searched directly | No description text is needed as an intermediary |
|
||||
| Lifecycle | **No separate GC, no refcounts, no keep-set**; deleting the block deletes the content | Media is a memory node, not a cache that needs keeping alive |
|
||||
|
||||
**How media is represented in plain-text memory** is the marker `[<mime> <short digest>] <description>`:
|
||||
**Description-based indexing is gone**: the old implementation embedded a
|
||||
`[<mime> <short digest>] <description>` marker in the body and treated the description as the
|
||||
semantic memory (retrieval used it). That path was removed wholesale in v1.2.0: a description is
|
||||
second-hand model output, and retrieving "someone else's paraphrase of an image" is strictly worse
|
||||
than retrieving the image's own vector. Images are now retrieved only by their own vector in the
|
||||
unified space, and no media marker is written into the body.
|
||||
|
||||
```
|
||||
[image/png a1b2c3d4e5f6] a purple-blue-red three-band chart
|
||||
```
|
||||
|
||||
Why it must ride on text: `Doc.Content`, `sentences.text` and text memory's `Input` are all strings
|
||||
— there is no field to carry structured data. **The description text is the durable semantic
|
||||
memory** (retrieval uses it); the digest is the key back to the bytes (reverse lookup uses it).
|
||||
After capacity GC evicts a blob, the description remains in the L0/L2/L3 text.
|
||||
**Cross-space vector migration**: media rows store their vector together with `vec_model` (the space
|
||||
fingerprint). At startup `reembedStaleMedia()` recomputes and **writes back** every row whose
|
||||
`vec_model` is empty (never embedded) or differs from the current space (model/dimension switched).
|
||||
Modalities outside the space return `ErrModalityUnsupported` — the kernel **never substitutes
|
||||
another model's vector**.
|
||||
|
||||
The media store is **optional throughout**: with `core.memory.media.enabled=false` or no
|
||||
configuration, the whole chain silently degrades to plain-text behaviour — no errors, no panics.
|
||||
@ -292,7 +307,7 @@ VM built-ins: `json.encode` / `json.decode` / `log` / `http_get` / `http_post`.
|
||||
| Method | Registration Mechanism | Compilation | Usage |
|
||||
|--------|----------------------|-------------|-------|
|
||||
| Built-in | `init()` → `RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. |
|
||||
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `plugindev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
|
||||
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `hmapdev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
|
||||
| Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. |
|
||||
| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
|
||||
|
||||
@ -311,7 +326,7 @@ Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter exe
|
||||
| Dimension | Built-in Plugin | External Plugin |
|
||||
|-----------|----------------|-----------------|
|
||||
| Registration | `init()` calls `plugin.RegisterFactory(name, factory)` | Implements `NewPluginFactory(name, config) (sdk.Plugin, error)` entry function |
|
||||
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `plugindev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
|
||||
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `hmapdev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
|
||||
| Distribution | Bundled with kernel, not independently installable | `.hmap` package (ZIP archive), installed via WebUI or pluginmgr API |
|
||||
| Metadata | `plugin.RegisterPluginMeta()` for display name | `plugin.json` manifest file (name, version, entry, platforms, capabilities, etc.) |
|
||||
| Plugin directory | No separate directory, compiled into binary | `plugins/<name>/` independent directory with `plugin.json` + `plugin.bin` |
|
||||
|
||||
@ -29,75 +29,80 @@ type Plugin interface {
|
||||
|
||||
| Method | Use Case | Complexity |
|
||||
|--------|----------|------------|
|
||||
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
|
||||
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `hmapdev` toolchain |
|
||||
| **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo |
|
||||
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `plugindev init --lua` |
|
||||
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `hmapdev init --lua` |
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
## 1. Quick Start: Using the plugindev Toolchain
|
||||
## 1. Quick Start: Using the hmapdev Toolchain
|
||||
|
||||
`plugindev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua plugin types.
|
||||
`hmapdev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua
|
||||
plugin types, and producing `.hmap` plugin bundles (the tool is named after that package format).
|
||||
|
||||
> Rename note: as of 1.2.0 the toolchain was renamed from `plugindev` to `hmapdev`; the SDK store moved from
|
||||
> `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk` (the old directory keeps working automatically).
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd homeagent-sdk/tools/plugindev
|
||||
go build -o plugindev
|
||||
# Add plugindev to PATH or use directly
|
||||
cd homeagent-sdk/tools/hmapdev
|
||||
go build -o hmapdev
|
||||
# Add hmapdev to PATH or use directly
|
||||
# Prebuilt binaries also ship as release assets (hmapdev_linux_amd64, ...)
|
||||
```
|
||||
|
||||
### SDK Version Management
|
||||
|
||||
`plugindev sdk` manages local SDK versions:
|
||||
`hmapdev sdk` manages local SDK versions:
|
||||
|
||||
```bash
|
||||
plugindev sdk list # list installed SDK versions
|
||||
plugindev sdk current # show current SDK version
|
||||
plugindev sdk latest # show latest available version
|
||||
plugindev sdk install v0.8.0 # install a specific version
|
||||
plugindev sdk use v0.8.0 # switch to a version
|
||||
plugindev sdk path # show current SDK path
|
||||
hmapdev sdk list # list installed SDK versions
|
||||
hmapdev sdk current # show current SDK version
|
||||
hmapdev sdk latest # show latest available version
|
||||
hmapdev sdk install v1.2.0 # install a specific version
|
||||
hmapdev sdk use v1.2.0 # switch to a version
|
||||
hmapdev sdk path # show current SDK path
|
||||
```
|
||||
|
||||
SDK is stored at `~/.homeagent/plugindev/sdk/<version>/`; `plugindev init` reads the current SDK version for `go.mod`.
|
||||
SDK is stored at `~/.homeagent/hmapdev/sdk/<version>/`; `hmapdev init` reads the current SDK version for `go.mod`.
|
||||
|
||||
### Source Debugging
|
||||
|
||||
`plugindev debug` interprets plugin source and prints a call trace, no compilation environment needed:
|
||||
`hmapdev debug` interprets plugin source and prints a call trace, no compilation environment needed:
|
||||
|
||||
```bash
|
||||
plugindev debug [dir] # dir defaults to the current directory
|
||||
hmapdev debug [dir] # dir defaults to the current directory
|
||||
```
|
||||
|
||||
### Creating a Go Plugin
|
||||
|
||||
```bash
|
||||
plugindev init myplugin
|
||||
hmapdev init myplugin
|
||||
cd myplugin
|
||||
# Edit plugin code
|
||||
vim plugin.go
|
||||
# Build and package (default is a multi-platform bundle, see below)
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# Output: dist/myplugin_bundle.hmap
|
||||
# Single-platform build:
|
||||
plugindev build --no-bundle
|
||||
hmapdev build --no-bundle
|
||||
# Output: dist/myplugin_linux_amd64.hmap (or windows_amd64)
|
||||
```
|
||||
|
||||
### Creating a Lua Plugin
|
||||
|
||||
```bash
|
||||
plugindev init myluaplugin --lua
|
||||
hmapdev init myluaplugin --lua
|
||||
cd myluaplugin
|
||||
# Edit plugin code
|
||||
vim main.lua
|
||||
# Local test
|
||||
lua main.lua
|
||||
# Build and package
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# Output: dist/myluaplugin_lua.hmap
|
||||
```
|
||||
|
||||
@ -128,16 +133,16 @@ myluaplugin/
|
||||
|
||||
### Build & Package
|
||||
|
||||
`plugindev build` automatically handles compilation and packaging:
|
||||
`hmapdev build` automatically handles compilation and packaging:
|
||||
|
||||
```bash
|
||||
cd myplugin
|
||||
plugindev build # default bundle mode (multi-platform)
|
||||
plugindev build --no-bundle # single-target build (per plg.json targets)
|
||||
plugindev build --target linux/amd64 # append a target on top of plg.json targets
|
||||
plugindev build --outdir dist # output directory (default: dist)
|
||||
plugindev build --sdk-path <path> # SDK path override (go.mod replace)
|
||||
plugindev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
||||
hmapdev build # default bundle mode (multi-platform)
|
||||
hmapdev build --no-bundle # single-target build (per plg.json targets)
|
||||
hmapdev build --target linux/amd64 # append a target on top of plg.json targets
|
||||
hmapdev build --outdir dist # output directory (default: dist)
|
||||
hmapdev build --sdk-path <path> # SDK path override (go.mod replace)
|
||||
hmapdev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
||||
```
|
||||
|
||||
Execution process:
|
||||
@ -171,7 +176,7 @@ the kernel picks the one matching the current platform and renames it to `plugin
|
||||
> - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel
|
||||
> skips legacy artifacts with an actionable error instead of crashing.
|
||||
> - **Business code needs no changes** — the public SDK interface is unchanged; just
|
||||
> rebuild with the new `plugindev`.
|
||||
> rebuild with the new `hmapdev` (formerly `plugindev`).
|
||||
> - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving
|
||||
> `plugin.so` there is harmless); it only distinguishes Lua plugins.
|
||||
> - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain.
|
||||
@ -180,12 +185,12 @@ the kernel picks the one matching the current platform and renames it to `plugin
|
||||
|
||||
### Build Targets & Multi-platform Bundle
|
||||
|
||||
**`plugindev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
|
||||
**`hmapdev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
|
||||
|
||||
```bash
|
||||
plugindev build # default bundle, outputs dist/myplugin_bundle.hmap
|
||||
plugindev build --bundle # explicitly enable bundle (same as above)
|
||||
plugindev build --no-bundle # disable bundle, build per plg.json targets
|
||||
hmapdev build # default bundle, outputs dist/myplugin_bundle.hmap
|
||||
hmapdev build --bundle # explicitly enable bundle (same as above)
|
||||
hmapdev build --no-bundle # disable bundle, build per plg.json targets
|
||||
```
|
||||
|
||||
Notes:
|
||||
@ -275,7 +280,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
|
||||
### Entry Point
|
||||
|
||||
`plugindev init` generates `plugin.go` with the `NewPlugin` export function directly,
|
||||
`hmapdev init` generates `plugin.go` with the `NewPlugin` export function directly,
|
||||
which is the entry point when the kernel loads the plugin:
|
||||
|
||||
```go
|
||||
@ -284,7 +289,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
}
|
||||
```
|
||||
|
||||
At build time, `plugindev build` auto-generates subprocess runtime code
|
||||
At build time, `hmapdev build` auto-generates subprocess runtime code
|
||||
(`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` /
|
||||
`z_proc_shm_windows.go`). All three platforms share the same entry point and the same
|
||||
RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on
|
||||
|
||||
@ -90,8 +90,8 @@ eventLoop() → processTextInput()
|
||||
RelevanceContext — 内存 events[] + JSON持久化
|
||||
Append: 每次输入, CleanText → 三分支向量(textForVector)
|
||||
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
|
||||
StaticEmbedder 预训练词嵌入 / TF-IDF 回退
|
||||
Prune: StaticEmbedder CosineSimilarity, 保留 topK + 最近10条
|
||||
向量层级:统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退)
|
||||
Prune: DenseCosine(仅同指纹才比较)→ 退化 StaticEmbedder CosineSimilarity;保留 topK + 最近10条
|
||||
├── 保留 → timeline → 按时间排序 → system prompt
|
||||
└── 低分 → Document 层归档 (原始时间戳)
|
||||
Save: 5s debounce 写盘
|
||||
@ -99,7 +99,7 @@ eventLoop() → processTextInput()
|
||||
↓ Prune 归档 ↑ LLM 主动召回
|
||||
|
||||
② Document (文件记忆)
|
||||
DocStore — JSON文件 + 与 Context 共享的 StaticEmbedder 向量空间(兜底: TF-IDF InvertedIndex)
|
||||
DocStore — JSON文件 + 稠密向量(统一多模态空间;dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex)
|
||||
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
|
||||
读取:
|
||||
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
|
||||
@ -132,11 +132,20 @@ eventLoop() → processTextInput()
|
||||
→ 三元组 → GraphDB.Commit
|
||||
```
|
||||
|
||||
### 向量化:预训练词嵌入 + TF-IDF 回退
|
||||
### 向量化:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)
|
||||
|
||||
所有向量化统一使用 `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
||||
向量化按可用性分三层降级,**每一层缺位都明确报错,不静默假装成功**:
|
||||
|
||||
**主策略 — 预训练词嵌入(词对齐 300 维)**
|
||||
**① 统一多模态空间(主路径,v1.2.0 起)**
|
||||
文本与图像共用**同一模型、同一维度、同一指纹**(默认 `chineseclip`:512 维、Apache-2.0、中文原生;
|
||||
亦可选 `qwen3vl` 或外部 `http` provider)。provider 经 `pkg/embedding` 公共 SPI 注册,
|
||||
**内核不硬编码任何模型**。向量与指纹一起持久化(`dense_fp` / `vec_model`),
|
||||
与当前指纹不一致即触发重算;融合时只接受**同指纹且同维度**的块向量
|
||||
(跨坐标系的向量混进去会算出两边都不像的方向)。
|
||||
|
||||
**② 词嵌入(文本兜底)** — `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
||||
|
||||
**模型来源**(词对齐 300 维)
|
||||
- 模型来源:ConceptNet Numberbatch(77 语对齐)/ fastText 中文 / fastText 英文
|
||||
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
|
||||
- 路径名含 `numberbatch` → 自动下载 ConceptNet,含 `cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
|
||||
@ -194,30 +203,31 @@ eventLoop() → processTextInput()
|
||||
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。
|
||||
`memory_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
|
||||
|
||||
### 媒体记忆(v1.1.0 起)
|
||||
### 媒体记忆(v1.2.0 起:一等记忆块)
|
||||
|
||||
`internal/memory/media/` — `Store`,内容寻址(CAS)
|
||||
媒体不是外挂内容,而是**记忆的一等节点**:`internal/memory/media/` 是内容寻址仓储(CAS),
|
||||
图数据库里的 block 节点携带它的 digest 与向量,结构边(如 `sentence --contains--> block`)表达归属。
|
||||
|
||||
| 关注点 | 做法 | 为何 |
|
||||
|---|---|---|
|
||||
| 寻址 | sha256 digest,元数据在 SQLite、blob 在磁盘 | 相同字节只存一份;元数据要可查询,blob 不该进数据库 |
|
||||
| 寻址 | sha256 digest;元数据在 SQLite,blob 在磁盘(`blobs/<前2位>/<其余>` 两级分桶) | 相同字节只存一份;元数据要可查询,blob 不该进数据库 |
|
||||
| 完整性 | 每次 `Get` 重校 digest | 磁盘损坏时静默返回脏数据比报错危险得多 |
|
||||
| 写入原子性 | `.tmp` + rename | 半个文件被当成完整内容会永久污染那个 digest |
|
||||
| 引用 | `owner_kind/owner_id/digest` 三元组主键,`AddRef` 幂等 | 三个 owner 类型:`context`(上下文事件)、`document`(文档)、`graph_sentence`(图谱句子) |
|
||||
| GC | 两阶段 + `minAge`,**有引用者绝不删** | 描述文本留在文本层,blob 可淘汰——语义记忆与字节缓存分离 |
|
||||
| 检索 | 块携带**自己的多模态向量与指纹**,直接参与向量检索 | 不需要描述文本做中介 |
|
||||
| 生命周期 | **无独立 GC、无引用计数、无 keep-set**;删除块即删内容 | 媒体是记忆节点,不是需要保活的缓存 |
|
||||
|
||||
**媒体在纯文本记忆里的表示**是标记 `[<mime> <短digest>] <描述>`:
|
||||
**不再有描述式索引**:旧实现在正文里写 `[<mime> <短digest>] <描述>` 标记,并把描述文本当作语义记忆
|
||||
(检索靠描述)。该机制已在 v1.2.0 整体拆除:描述是模型生成的二手信息,
|
||||
检索“别人转述的图片”不如检索图片自己的向量。现在图片只按自己的统一空间向量被检索,
|
||||
正文里不再有 media marker。
|
||||
|
||||
```
|
||||
[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||
```
|
||||
**跨空间向量迁移**:媒体行的向量带 `vec_model`(空间指纹)。启动时
|
||||
`reembedStaleMedia()` 把 `vec_model` 为空(从未嵌入)或与当前空间不一致(换过模型/维度)的行
|
||||
批量重算并**写回库**;模态不在本空间覆盖范围时返回 `ErrModalityUnsupported`,
|
||||
**绝不拿别的模型的向量顶替**。
|
||||
|
||||
之所以必须借文本承载:`Doc.Content`、`sentences.text`、文本记忆的 `Input` 全是字符串,
|
||||
没有字段能挂结构化数据。**描述文本才是持久的语义记忆**(检索靠它),digest 是回到字节的
|
||||
钥匙(反查靠它)。blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里。
|
||||
|
||||
媒体存储**全程可选**:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为
|
||||
纯文本行为,不报错不 panic。
|
||||
媒体存储全程可选:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为纯文本行为,
|
||||
不报错不 panic。
|
||||
|
||||
### 其他记忆层
|
||||
|
||||
@ -287,7 +297,7 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
|
||||
| 方式 | 注册机制 | 编译 | 用途 |
|
||||
|------|----------|------|------|
|
||||
| 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
|
||||
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `plugindev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
|
||||
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
|
||||
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
|
||||
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
|
||||
|
||||
@ -306,7 +316,7 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
|
||||
| 维度 | 内置插件 | 外部插件 |
|
||||
|------|----------|----------|
|
||||
| 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 |
|
||||
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `plugindev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo),内核 spawn 为子进程 |
|
||||
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `hmapdev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo),内核 spawn 为子进程 |
|
||||
| 分发方式 | 随内核分发,不可独立安装/卸载 | `.hmap` 包(ZIP 归档),通过 WebUI 或 pluginmgr API 安装 |
|
||||
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件(name, version, entry, platforms, capabilities 等) |
|
||||
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + `plugin.bin` |
|
||||
|
||||
@ -30,75 +30,80 @@ type Plugin interface {
|
||||
|
||||
| 方式 | 适用场景 | 复杂度 |
|
||||
|------|---------|--------|
|
||||
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
|
||||
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `hmapdev` 工具链生成 |
|
||||
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
|
||||
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 |
|
||||
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `hmapdev init --lua` 生成 |
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
## 一、快速开始:使用 plugindev 工具链
|
||||
## 一、快速开始:使用 hmapdev 工具链
|
||||
|
||||
`plugindev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型。
|
||||
`hmapdev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型,
|
||||
最终产出 `.hmap` 插件包(工具名即来自这个包格式)。
|
||||
|
||||
> 改名说明:1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由
|
||||
> `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`(旧目录会自动继续沿用)。
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
cd homeagent-sdk/tools/plugindev
|
||||
go build -o plugindev
|
||||
# 将 plugindev 加入 PATH 或直接使用
|
||||
cd homeagent-sdk/tools/hmapdev
|
||||
go build -o hmapdev
|
||||
# 将 hmapdev 加入 PATH 或直接使用
|
||||
# 也可从 SDK 的 release 附件下载预编译二进制(hmapdev_linux_amd64 等)
|
||||
```
|
||||
|
||||
### SDK 版本管理
|
||||
|
||||
`plugindev sdk` 子命令管理本地 SDK 版本:
|
||||
`hmapdev sdk` 子命令管理本地 SDK 版本:
|
||||
|
||||
```bash
|
||||
plugindev sdk list # 列出已安装的 SDK 版本
|
||||
plugindev sdk current # 显示当前使用的 SDK 版本
|
||||
plugindev sdk latest # 显示最新可用版本
|
||||
plugindev sdk install v0.8.0 # 安装指定版本
|
||||
plugindev sdk use v0.8.0 # 切换使用版本
|
||||
plugindev sdk path # 显示当前 SDK 路径
|
||||
hmapdev sdk list # 列出已安装的 SDK 版本
|
||||
hmapdev sdk current # 显示当前使用的 SDK 版本
|
||||
hmapdev sdk latest # 显示最新可用版本
|
||||
hmapdev sdk install v1.2.0 # 安装指定版本
|
||||
hmapdev sdk use v1.2.0 # 切换使用版本
|
||||
hmapdev sdk path # 显示当前 SDK 路径
|
||||
```
|
||||
|
||||
SDK 存储在 `~/.homeagent/plugindev/sdk/<version>/`,`plugindev init` 自动读取当前 SDK 版本填充 `go.mod`。
|
||||
SDK 存储在 `~/.homeagent/hmapdev/sdk/<version>/`,`hmapdev init` 自动读取当前 SDK 版本填充 `go.mod`。
|
||||
|
||||
### 源码调试
|
||||
|
||||
`plugindev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
|
||||
`hmapdev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
|
||||
|
||||
```bash
|
||||
plugindev debug [dir] # dir 默认当前目录
|
||||
hmapdev debug [dir] # dir 默认当前目录
|
||||
```
|
||||
|
||||
### 创建 Go 插件
|
||||
|
||||
```bash
|
||||
plugindev init myplugin
|
||||
hmapdev init myplugin
|
||||
cd myplugin
|
||||
# 编辑插件代码
|
||||
vim plugin.go
|
||||
# 编译打包
|
||||
plugindev build # 默认多平台 bundle(见下节)
|
||||
hmapdev build # 默认多平台 bundle(见下节)
|
||||
# 输出: dist/myplugin_bundle.hmap
|
||||
# 单平台构建:
|
||||
plugindev build --no-bundle
|
||||
hmapdev build --no-bundle
|
||||
# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64)
|
||||
```
|
||||
|
||||
### 创建 Lua 插件
|
||||
|
||||
```bash
|
||||
plugindev init myluaplugin --lua
|
||||
hmapdev init myluaplugin --lua
|
||||
cd myluaplugin
|
||||
# 编辑插件代码
|
||||
vim main.lua
|
||||
# 本地测试
|
||||
lua main.lua
|
||||
# 编译打包
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# 输出: dist/myluaplugin_lua.hmap
|
||||
```
|
||||
|
||||
@ -129,16 +134,16 @@ myluaplugin/
|
||||
|
||||
### 编译打包
|
||||
|
||||
`plugindev build` 会自动完成编译和打包:
|
||||
`hmapdev build` 会自动完成编译和打包:
|
||||
|
||||
```bash
|
||||
cd myplugin
|
||||
plugindev build # 默认 bundle 模式(多平台合集)
|
||||
plugindev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
||||
plugindev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
||||
plugindev build --outdir dist # 指定输出目录(默认 dist)
|
||||
plugindev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
||||
plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
|
||||
hmapdev build # 默认 bundle 模式(多平台合集)
|
||||
hmapdev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
||||
hmapdev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
||||
hmapdev build --outdir dist # 指定输出目录(默认 dist)
|
||||
hmapdev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
||||
hmapdev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
|
||||
```
|
||||
|
||||
执行过程:
|
||||
@ -154,7 +159,7 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
|
||||
| 文件 | 用途 | 关键字段 |
|
||||
|------|------|---------|
|
||||
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"`);`bundle` — 多平台合集开关(默认 `true`)|
|
||||
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
||||
| `plugin.json` | 构建产物清单,`hmapdev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
||||
|
||||
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**:
|
||||
|
||||
@ -169,7 +174,7 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
|
||||
>
|
||||
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
|
||||
> 会跳过并报可操作错误,不崩溃。
|
||||
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `plugindev` 重编。
|
||||
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `hmapdev`(原 `plugindev`)重编。
|
||||
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
|
||||
> 它现在只用于区分 Lua 插件。
|
||||
> - 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
|
||||
@ -178,12 +183,12 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
|
||||
|
||||
### 构建目标与多平台打包(bundle)
|
||||
|
||||
**`plugindev build` 默认就是 bundle 模式**(`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64,生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
|
||||
**`hmapdev build` 默认就是 bundle 模式**(`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64,生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
|
||||
|
||||
```bash
|
||||
plugindev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
||||
plugindev build --bundle # 显式开启 bundle(同上)
|
||||
plugindev build --no-bundle # 关闭 bundle,按 plg.json 的 targets 逐平台构建
|
||||
hmapdev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
||||
hmapdev build --bundle # 显式开启 bundle(同上)
|
||||
hmapdev build --no-bundle # 关闭 bundle,按 plg.json 的 targets 逐平台构建
|
||||
```
|
||||
|
||||
注意:
|
||||
@ -273,7 +278,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
|
||||
### 入口点
|
||||
|
||||
`plugindev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
|
||||
`hmapdev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
|
||||
|
||||
```go
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
@ -281,7 +286,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
}
|
||||
```
|
||||
|
||||
编译时 `plugindev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑,仅跨进程资源传递机制不同(Unix 继承 fd,Windows 命名内核对象)。
|
||||
编译时 `hmapdev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑,仅跨进程资源传递机制不同(Unix 继承 fd,Windows 命名内核对象)。
|
||||
|
||||
### PluginSDK 核心 API
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
@ -42,10 +43,21 @@ import (
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
|
||||
// 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。
|
||||
// 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/chineseclip"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
|
||||
// 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
|
||||
// platform_windows.go。
|
||||
requireSupportedPlatform()
|
||||
|
||||
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
|
||||
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
|
||||
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
|
||||
@ -320,20 +332,22 @@ func main() {
|
||||
// 文档记忆 + 知识库
|
||||
// ========================================================================
|
||||
|
||||
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"))
|
||||
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
// 关停时落盘。文档记忆的内存态变更(迁移结果、访问计数等)只在 flush
|
||||
// 里写盘,而 flush 的唯一入口是 Stop()——此前全仓无人调用它,
|
||||
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||
defer docStore.Stop()
|
||||
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
||||
// L0/L2/L3 只记 digest。开关默认开;关闭后全部媒体接线静默跳过,
|
||||
// 对话行为与本特性上线前完全一致。
|
||||
// 媒体存储(内容寻址):记忆块的内容后端。
|
||||
// 开关默认开;关闭后全部媒体接线静默跳过,对话行为与本特性上线前一致。
|
||||
var mediaStore *media.Store
|
||||
if cfgReg.GetBool("core.memory.media.enabled", true) {
|
||||
mediaDir := cfgReg.GetString("core.memory.media.dir",
|
||||
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
|
||||
maxMB := cfgReg.GetInt("core.memory.media.max_mb", 2048)
|
||||
ms, err := media.New(mediaDir, int64(maxMB)*1024*1024)
|
||||
ms, err := media.New(mediaDir)
|
||||
if err != nil {
|
||||
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
|
||||
log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err)
|
||||
@ -341,8 +355,48 @@ func main() {
|
||||
mediaStore = ms
|
||||
defer mediaStore.Close()
|
||||
st := mediaStore.Stats()
|
||||
log.Printf("[homed] media store active: %v 条 / %v 字节(上限 %d MB)",
|
||||
st["count"], st["total_bytes"], maxMB)
|
||||
log.Printf("[homed] media store active: %v 条 / %v 字节",
|
||||
st["count"], st["total_bytes"])
|
||||
}
|
||||
}
|
||||
|
||||
// 统一多模态向量空间。
|
||||
//
|
||||
// 核心**不**知道任何具体模型:它只按配置里的 provider 名从公共注册表
|
||||
// (pkg/embedding)打开一个 provider,并把 options.* 原样交给它。模型文件
|
||||
// 布局、预处理、解码、运行时全部属于 provider 内部实现。
|
||||
// provider 名为空时禁用多模态向量检索,退回纯 fastText 文本路径。
|
||||
var multimodalSpace vector.MultimodalEmbedder
|
||||
// 这两个值只用于状态报告(healthcheck_kernel 的 onnx 段):
|
||||
// 「配了哪个 provider」与「为什么没启用」,避免只能看到 false 却不知原因。
|
||||
var mmProviderName, mmErr string
|
||||
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
|
||||
mmProviderName = mmProvider
|
||||
opts := map[string]string{}
|
||||
const optPrefix = "core.memory.multimodal_space.options."
|
||||
for _, key := range cfgReg.List("core.memory.multimodal_space.options.") {
|
||||
opts[strings.TrimPrefix(key, optPrefix)] = cfgReg.GetString(key, "")
|
||||
}
|
||||
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
|
||||
if err != nil {
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v(多模态向量检索已禁用;已注册: %s)",
|
||||
mmProvider, err, strings.Join(embedding.Names(), ", "))
|
||||
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
|
||||
provider.Close()
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v(多模态向量检索已禁用)", mmProvider, err)
|
||||
} else {
|
||||
multimodalSpace = adapted
|
||||
defer adapted.Close()
|
||||
info := provider.Info()
|
||||
// 指纹可能很长(模型文件哈希),日志里只取前 12 个字符便于对照。
|
||||
shortFP := info.Fingerprint
|
||||
if len(shortFP) > 12 {
|
||||
shortFP = shortFP[:12]
|
||||
}
|
||||
log.Printf("[homed] multimodal space active: provider=%s dim=%d fp=%s modalities=%v",
|
||||
mmProvider, info.Dimension, shortFP, info.Modalities)
|
||||
}
|
||||
}
|
||||
|
||||
@ -357,13 +411,29 @@ func main() {
|
||||
// 人格设定
|
||||
// ========================================================================
|
||||
|
||||
// 人格来源优先级:personal/personal.md(高级覆盖,存在且非空才生效)
|
||||
// > 配置项 core.agent.personal_prompt(默认模板 = config.DefaultPersonaPrompt)。
|
||||
//
|
||||
// 曾经只有「文件」一个来源且无人维护,导致人格卡写死旧版本号与已删除的 C ABI、
|
||||
// 反过来让实例自称旧版本(v1.2.0 压测发现)。故:
|
||||
// - 配置项化 + 内置默认模板(不含版本号字面量)
|
||||
// - 文件仍在时生效,但扫到腐坏内容就在启动日志里明确告警
|
||||
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: load personality: %v", err)
|
||||
}
|
||||
if personality != nil && personality.Content != "" {
|
||||
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
|
||||
log.Printf("[homed] 人格来源=文件 %s(优先于配置项),%d 字节", personalPath, len(personality.Content))
|
||||
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
|
||||
log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+
|
||||
"(默认模板不含版本号,被问版本时以运行时快照为准)", hints)
|
||||
}
|
||||
} else if pv := cfgReg.GetString("core.agent.personal_prompt", internalConfig.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
|
||||
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
|
||||
log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt,%d 字节", len(pv))
|
||||
} else {
|
||||
log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@ -442,23 +512,23 @@ func main() {
|
||||
}
|
||||
|
||||
agent := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
MediaGCInterval: cfgReg.GetDuration("core.memory.media.gc_interval", 6*time.Hour),
|
||||
MediaGCMinAge: cfgReg.GetDuration("core.memory.media.gc_min_age", time.Hour),
|
||||
MediaDescribe: cfgReg.GetBool("core.memory.media.describe_on_ingest", false),
|
||||
Personality: personality,
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
Personality: personality,
|
||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
||||
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
||||
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
||||
@ -468,6 +538,9 @@ func main() {
|
||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
||||
Embedder: embedder,
|
||||
MultimodalSpace: multimodalSpace,
|
||||
EmbeddingProvider: mmProviderName,
|
||||
EmbeddingError: mmErr,
|
||||
StageHost: stageHost,
|
||||
EventBus: evBus,
|
||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||
|
||||
9
cmd/homed/platform_other.go
Normal file
9
cmd/homed/platform_other.go
Normal file
@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
// requireSupportedPlatform 在受支持的平台上不做任何事。
|
||||
//
|
||||
// 平台策略见 platform_windows.go:只有 homed 放弃 Windows 原生支持
|
||||
// (插件体系依赖 fd 继承与共享内存段内偏移),Windows 用户走 WSL2。
|
||||
func requireSupportedPlatform() {}
|
||||
44
cmd/homed/platform_windows.go
Normal file
44
cmd/homed/platform_windows.go
Normal file
@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// requireSupportedPlatform 在原生 Windows 上直接拒绝启动 homed。
|
||||
//
|
||||
// 为什么不做原生支持(不是「还没来得及做」,是设计上不做):
|
||||
//
|
||||
// homed 的插件体系建立在两个原语上——**继承的 fd**(Single memfd: 统一共享
|
||||
// 内存区 + eventfd 通知)与**同段内相对偏移解引用**(各进程 mmap 到不同虚拟
|
||||
// 基址,段内一律用偏移互相读写,这样插件回调才能就地改写内核看到的那份数据)。
|
||||
//
|
||||
// Windows 的等价物是命名内核对象(CreateFileMappingW / OpenEventW)+句柄表,
|
||||
// 没有 fd 继承语义(os/exec 的 ExtraFiles 在 Windows 上直接不被支持),
|
||||
// 生命周期与权限模型也按句柄而非进程继承来组织。要在其上重建这套语义,
|
||||
// 等于再维护一套平台专属 ABI 与安全边界——而 C ABI 时代正是「三套 ABI 并存
|
||||
// 导致改写型插件在某个平台上静默失效」的教训(§9.2)。
|
||||
//
|
||||
// 所以选择:**原生 Windows 不提供 homed**。Windows 用户跑 WSL2——
|
||||
// WSL2 里就是普通 linux/amd64,走与我们测试矩阵完全相同的那条路径。
|
||||
//
|
||||
// 注意范围:只有 homed 如此。hmapdev 工具链仍可在 Windows 上运行
|
||||
// (在 Windows 上开发、为 WSL 构建 linux 插件是合理工作流)。
|
||||
func requireSupportedPlatform() {
|
||||
fmt.Fprintln(os.Stderr, "homed 不支持 Windows 原生运行。")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "原因:子进程插件依赖 fd 继承 + 统一共享内存区的段内偏移解引用,")
|
||||
fmt.Fprintln(os.Stderr, "而 Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属")
|
||||
fmt.Fprintln(os.Stderr, "ABI——C ABI 时代三套 ABI 并存曾导致改写型插件在某个平台上静默失效。")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "请改用 WSL2:")
|
||||
fmt.Fprintln(os.Stderr, " 1. wsl --install -d Ubuntu # 安装 WSL2")
|
||||
fmt.Fprintln(os.Stderr, " 2. 在 WSL 内下载 linux/amd64 的 homed 与插件(.hmap)")
|
||||
fmt.Fprintln(os.Stderr, " 3. 在 WSL 内运行 homed:与 Linux 主机完全相同,无需额外配置")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "数据目录可放在 /mnt/c/... 下以便与 Windows 侧共享,")
|
||||
fmt.Fprintln(os.Stderr, "但不建议(跨文件系统 IO 慢、inotify 语义受限);推荐放在 WSL 内部路径。")
|
||||
os.Exit(2)
|
||||
}
|
||||
@ -17,6 +17,36 @@ func randomSecret(n int) string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// must 让失败真正停下来。
|
||||
//
|
||||
// 这里曾经把所有 db.Exec 的返回值丢掉,配合 CGO_ENABLED=0 构建(go-sqlite3
|
||||
// 退化成静态桩),得到的是一个**完全静默的空操作**:打印凭据、退出码 0、
|
||||
// config.db 里一个字节都没写。调用方(安装脚本)无法区分成败,用户装完
|
||||
// 照着 credentials.txt 登录必然失败。
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// verify 回读刚写入的值。
|
||||
//
|
||||
// 只看 Exec 有没有报错不够:驱动被换掉(如上面的桩)、路径不对、写入被丢弃,
|
||||
// 都可能返回 nil 而什么都没落下。这里把真实落盘的值读回来,与预期逐一比对,
|
||||
// 不一致就非零退出——"初始化脚本说自己成功了"必须由数据库内容佐证。
|
||||
func verify(db *sql.DB, table, key, want string) {
|
||||
var got string
|
||||
if err := db.QueryRow(fmt.Sprintf(`SELECT value FROM %s WHERE key = ?`, table), key).Scan(&got); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 回读 %s.%s 失败: %v\n", table, key, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if got != want {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: %s.%s 与写入值不一致(读回 %q)\n", table, key, got)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
dataDir := flag.String("data", "", "data directory")
|
||||
webuiUsername := flag.String("username", "admin", "webui username")
|
||||
@ -33,16 +63,24 @@ func main() {
|
||||
|
||||
dbPath := *dataDir + "/config.db"
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "open db: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
must(err)
|
||||
defer db.Close()
|
||||
|
||||
db.Exec("PRAGMA journal_mode=WAL")
|
||||
// 尽早验证数据库真的可用:sql.Open 是惰性的,不碰一次不会暴露驱动问题。
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 打开数据库 %s 失败: %v\n", dbPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
|
||||
db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, "webui.listen_addr", ":8080")
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 创建 config 表失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
const listenAddr = ":8080"
|
||||
if _, err := db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, "webui.listen_addr", listenAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 写入 webui.listen_addr 失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
pw := *webuiPassword
|
||||
if pw == "" {
|
||||
@ -53,13 +91,28 @@ func main() {
|
||||
apiKey = randomSecret(16)
|
||||
}
|
||||
|
||||
pt := "config_webui"
|
||||
db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, pt))
|
||||
const pt = "config_webui"
|
||||
if _, err := db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, pt)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 创建 %s 表失败: %v\n", pt, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ws := fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, pt)
|
||||
db.Exec(ws, "api_key", apiKey)
|
||||
db.Exec(ws, "username", *webuiUsername)
|
||||
db.Exec(ws, "password", pw)
|
||||
db.Exec(ws, "session_ttl_hours", "24")
|
||||
for _, kv := range [][2]string{
|
||||
{"api_key", apiKey},
|
||||
{"username", *webuiUsername},
|
||||
{"password", pw},
|
||||
{"session_ttl_hours", "24"},
|
||||
} {
|
||||
if _, err := db.Exec(ws, kv[0], kv[1]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "initconfig: 写入 %s.%s 失败: %v\n", pt, kv[0], err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
verify(db, pt, "api_key", apiKey)
|
||||
verify(db, pt, "username", *webuiUsername)
|
||||
verify(db, pt, "password", pw)
|
||||
verify(db, "config", "webui.listen_addr", listenAddr)
|
||||
|
||||
fmt.Printf("API_KEY=%s\n", apiKey)
|
||||
fmt.Printf("WEBUI_USERNAME=%s\n", *webuiUsername)
|
||||
|
||||
@ -6,9 +6,11 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/homed -data /var/lib/homeagent
|
||||
ExecStart=/usr/bin/homed -data /var/lib/homeagent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment=ONNXRUNTIME_DIR=/usr/lib/homeagent/onnxruntime
|
||||
StateDirectory=homeagent
|
||||
StartLimitBurst=3
|
||||
StartLimitInterval=60s
|
||||
|
||||
@ -26,7 +28,9 @@ DeviceAllow=/dev/dsp rw
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=256
|
||||
MemoryMax=2G
|
||||
# Chinese-CLIP 本身实测约 1.15GB;加文档稠密索引、词向量、插件与
|
||||
# ORT arena 后生产实例约 4.5GB。2GB 会在首次全量建索引时被 cgroup OOM。
|
||||
MemoryMax=8G
|
||||
CPUQuota=100%
|
||||
|
||||
[Install]
|
||||
|
||||
@ -15,6 +15,13 @@ BUILD_TIME="${BUILD_TIME:-$(date -u '+%Y-%m-%dT%H:%M:%SZ')}"
|
||||
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
|
||||
LDFLAGS="-X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=${VERSION} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Commit=${COMMIT} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.BuildTime=${BUILD_TIME}"
|
||||
|
||||
# 版本与提交的**权威来源**是上面注入的 meta.Version / meta.Commit,不是 Go 自带的
|
||||
# VCS 戳。后者不进 build cache key(Go 文档明确说明 VCS 变化不会触发重建),
|
||||
# 命中缓存时会把上一次的 revision 一并带回来——实测发布分支的产物上就出现了
|
||||
# 1715b5c(本机任何仓库都不存在的提交),用 `go version -m` 溯源会指向幽灵提交。
|
||||
# 统一 -buildvcs=false:宁可没有这个信号,也不要一个错的。
|
||||
# 溯源请用:`strings homed | grep -m1 '^<短 hash>$'`(meta.Commit 是字符串常量)。
|
||||
|
||||
TARGET="${1:-native}"
|
||||
COMPONENT="${2:-all}"
|
||||
|
||||
@ -55,7 +62,7 @@ case "$TARGET" in
|
||||
;;
|
||||
*)
|
||||
echo "Unknown target: $TARGET"
|
||||
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all]"
|
||||
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|homed|waiter|initconfig|gui|payload]"
|
||||
echo " [all|homed|waiter|initconfig|gui]"
|
||||
exit 1
|
||||
esac
|
||||
@ -118,8 +125,21 @@ build_homed() {
|
||||
# Go 用 CC 驱动 CGO 编译与链接,用 CC 指定的交叉工具链来决定目标架构。
|
||||
# 必须同时 export CC 给 Go 的 CGO 代码生成器,否则 CGO_ENABLED=1 下的
|
||||
# 目标文件与 host 的 ld 不兼容(如 arm64 的 .o 给了 x86_64 的 ld)。
|
||||
#
|
||||
# HOMED_TAGS 默认带 onnxruntime:发行版**默认启用**本地向量空间。
|
||||
# 不带这个标签时 providers/chineseclip 与 providers/qwen3vl 仍会注册,
|
||||
# 但打开时报「requires build tag」并优雅降级(不静默假装成功)。
|
||||
# 需要极简构建时可显式 HOMED_TAGS= 关掉。
|
||||
#
|
||||
# 运行期还需要 libonnxruntime.so(provider 按 /opt/onnxruntime、
|
||||
# /usr/local/lib、/usr/lib 顺序查找);缺失时同样是「日志里的明确错误 +
|
||||
# 降级」,不会假装启用。
|
||||
local _cc="${CC:-cc}"
|
||||
CGO_ENABLED=1 CC="$_cc" "$GO" build -trimpath -installsuffix dynlink \
|
||||
local _tags="${HOMED_TAGS-onnxruntime}"
|
||||
local -a _tagargs=()
|
||||
if [ -n "$_tags" ]; then _tagargs=(-tags "$_tags"); fi
|
||||
CGO_ENABLED=1 CC="$_cc" "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
|
||||
${_tagargs[@]+"${_tagargs[@]}"} \
|
||||
-ldflags "$LDFLAGS" -o "$out" ./cmd/homed/
|
||||
echo " OK ($(file "$out" | sed 's/.*: //') | $(du -h "$out" | cut -f1))"
|
||||
}
|
||||
@ -131,28 +151,74 @@ build_waiter() {
|
||||
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
||||
|
||||
echo "[BUILD] waiter ${plat} → $out"
|
||||
CGO_ENABLED=0 "$GO" build -trimpath -installsuffix dynlink \
|
||||
CGO_ENABLED=0 "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
|
||||
-ldflags "$LDFLAGS" -o "$out" ./cmd/waiter/
|
||||
echo " OK ($(du -h "$out" | cut -f1))"
|
||||
}
|
||||
|
||||
# ---- initconfig (CGO-free 配置初始化器) ----
|
||||
# ---- initconfig(必须 cgo:写 config.db 用的是 go-sqlite3)----
|
||||
#
|
||||
# NSIS 安装包(installer.nsi:220 File "..\build\initconfig.exe")与
|
||||
# package-linux.sh 的 stage_variant 都引用它,但此前 build.sh 从不构建它——
|
||||
# Windows 安装包构建会直接失败在缺文件上。
|
||||
# 这里**必须** CGO_ENABLED=1。此前写的是 CGO_ENABLED=0,而 cmd/initconfig 通过
|
||||
# database/sql 使用 mattn/go-sqlite3:CGO_ENABLED=0 时该库退化成 static_mock.go
|
||||
# 里的桩,sql.Open 是懒的所以不报错、第一次 Exec 才失败;而 main.go 当时忽略
|
||||
# 了所有错误——于是 initconfig 打印凭据、退出码 0、一个字节都没写进 config.db。
|
||||
# 安装脚本把这份凭据写进 credentials.txt,用户照它登录必然失败,全程无报错。
|
||||
#
|
||||
# NSIS 安装包(installer.nsi)与 package-linux.sh 的 stage_variant 都引用它,
|
||||
# 但此前 build.sh 从不构建它——Windows 安装包构建会直接失败在缺文件上。
|
||||
build_initconfig() {
|
||||
local plat="${GOOS:-linux}/${GOARCH:-amd64}"
|
||||
local out="$BUILD_DIR/initconfig${SUFFIX:+_$SUFFIX}"
|
||||
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
|
||||
|
||||
echo "[BUILD] initconfig ${plat} → $out"
|
||||
CGO_ENABLED=0 "$GO" build -trimpath -installsuffix dynlink \
|
||||
CGO_ENABLED=1 "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
|
||||
-ldflags "$LDFLAGS" -o "$out" ./cmd/initconfig/
|
||||
echo " OK ($(du -h "$out" | cut -f1))"
|
||||
}
|
||||
|
||||
# ---- linux-payload(给 Windows 安装器用的 Linux 包)----
|
||||
#
|
||||
# Windows 不再安装 homed.exe:homed 依赖 fd 继承 + 统一共享内存区的段内偏移
|
||||
# 解引用,Windows 句柄模型无法表达(见 cmd/homed/platform_windows.go)。
|
||||
# Windows 安装器改为引导到 WSL2,并把 **Linux 包**送进发行版里安装。
|
||||
# 因此 Windows 安装包必须带上 Linux 产物——这一段就是把它暂存到
|
||||
# build/linux-payload/(installer.nsi 从这里 File /r 打进安装包)。
|
||||
#
|
||||
# 复用 package-linux.sh 的产物,而不是在这里另行编译:WSL 里跑的就是普通
|
||||
# linux/amd64,安装内容必须与 Linux 原生安装**完全一致**,否则又变成两个平台。
|
||||
stage_linux_payload() {
|
||||
local src="$PROJECT_ROOT/dist/linux"
|
||||
local out="$BUILD_DIR/linux-payload"
|
||||
|
||||
rm -rf "$out"
|
||||
mkdir -p "$out"
|
||||
|
||||
local found=0
|
||||
for f in "$src"/*.deb "$src"/*.tar.gz; do
|
||||
[ -f "$f" ] || continue
|
||||
cp "$f" "$out/"
|
||||
found=$((found + 1))
|
||||
done
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "[FAIL] build/linux-payload 为空:先运行 package-linux.sh 产出 dist/linux/*.deb|*.tar.gz" >&2
|
||||
echo " (Windows 安装器会把这里的包送进 WSL 安装;空包等于装不上)" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[BUILD] linux-payload ← $found 个包"
|
||||
ls -1 "$out" | sed 's/^/ /'
|
||||
}
|
||||
|
||||
# ---- gui (Electron) ----
|
||||
#
|
||||
# 输出目录必须用 --config.directories.output,**不能用 -o**:
|
||||
# electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的 Building 段),
|
||||
# 不是 output。此前 `-o "$BUILD_DIR"` 被当成 macOS 的 target 列表,报
|
||||
# ⨯ Unknown target: /home/program/trueagent/build
|
||||
# (路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的,
|
||||
# 这也是它看起来像「路径错」而实际是「参数位置错」的原因)。
|
||||
# v1.0.1 与 v1.0.3 两次发布都因此手工组装过 GUI。
|
||||
build_gui() {
|
||||
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
|
||||
echo "[SKIP] gui ${GOOS}/${GOARCH} — electron-builder handles cross-platform natively; run 'all' on CI host"
|
||||
@ -170,22 +236,52 @@ build_gui() {
|
||||
# 不传 --config:electron-builder 默认从 package.json 的 "build" 键读配置。
|
||||
# 传 --config package.json 会让它把**整个** package.json 当配置校验,
|
||||
# 于是 devDependencies / build / scripts 全被判为 "unknown property" 而失败。
|
||||
(cd "$gui_dir" && npx electron-builder \
|
||||
--linux --win --mac \
|
||||
--x64 --arm64 \
|
||||
-p never \
|
||||
-o "$BUILD_DIR")
|
||||
echo " OK"
|
||||
#
|
||||
# GUI 失败不中断整体构建:homed/waiter/initconfig 是发布的主体,
|
||||
# 而 GUI 依赖 electron 运行时下载(离线机器、arm64 缺缓存都会失败)。
|
||||
# set -e 下若不接住,一个可选组件会让整轮跨平台构建全废。
|
||||
if (cd "$gui_dir" && npx electron-builder \
|
||||
--linux --win --mac \
|
||||
--x64 --arm64 \
|
||||
-p never \
|
||||
--config.directories.output="$BUILD_DIR"); then
|
||||
echo " OK"
|
||||
else
|
||||
echo " WARN: gui 构建失败(可选组件,不影响 homed/waiter/initconfig)"
|
||||
echo " Linux 包可用 deploy/packaging/package-linux.sh 内置的手工组装路径"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- dispatch ----
|
||||
case "$COMPONENT" in
|
||||
all) build_homed; build_waiter; build_initconfig; build_gui ;;
|
||||
homed) build_homed ;;
|
||||
waiter) build_waiter ;;
|
||||
initconfig) build_initconfig ;;
|
||||
gui) build_gui ;;
|
||||
*)
|
||||
echo "Unknown component: $COMPONENT"
|
||||
exit 1
|
||||
esac
|
||||
if [ "${GOOS:-}" = "windows" ]; then
|
||||
# Windows 目标:构建的**不是** homed——它已放弃 Windows 原生支持。
|
||||
# 需要的是:Linux 包(送进 WSL 安装)+ Windows 侧客户端(waiter CLI / GUI)。
|
||||
case "$COMPONENT" in
|
||||
all) build_waiter; stage_linux_payload; build_gui ;;
|
||||
waiter) build_waiter ;;
|
||||
payload) stage_linux_payload ;;
|
||||
gui) build_gui ;;
|
||||
homed|initconfig)
|
||||
echo "homed/initconfig 不再提供 Windows 原生构建:请用 WSL2(或用 linux/amd64 目标)。" >&2
|
||||
echo "原因见 cmd/homed/platform_windows.go。" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown component: $COMPONENT"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
case "$COMPONENT" in
|
||||
all) build_homed; build_waiter; build_initconfig; build_gui ;;
|
||||
homed) build_homed ;;
|
||||
waiter) build_waiter ;;
|
||||
initconfig) build_initconfig ;;
|
||||
gui) build_gui ;;
|
||||
*)
|
||||
echo "Unknown component: $COMPONENT"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
# 此前硬编码 0.8.0 而 release 已到 1.0.0,装出来的包在「添加/删除程序」里
|
||||
# 会显示错误版本(DisplayVersion 也取自这个宏)。
|
||||
!ifndef PRODUCT_VERSION
|
||||
!define PRODUCT_VERSION "1.1.0"
|
||||
!define PRODUCT_VERSION "1.0.0"
|
||||
!endif
|
||||
|
||||
!if "${VARIANT}" == "full"
|
||||
@ -76,6 +76,10 @@ Function GenKey
|
||||
FunctionEnd
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
; 许可页:AGPL-3.0-only(全文在仓库根 LICENSE)。
|
||||
; NSIS 的 File/!insertmacro 相对路径以**本 .nsi 所在目录**为基准解析,
|
||||
; 而本文件在 deploy/packaging/,故仓库根是 ..\..\ 。
|
||||
!insertmacro MUI_PAGE_LICENSE "..\..\LICENSE"
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
!if "${HAS_CREDENTIALS}" == "1"
|
||||
@ -216,17 +220,26 @@ FunctionEnd
|
||||
|
||||
Section "Install" SEC_INSTALL
|
||||
SetOutPath "$INSTDIR"
|
||||
; WSL 引导脚本随安装包分发(它负责检测/引导 WSL 并把 Linux 包装进发行版)
|
||||
File "..\..\deploy\packaging\windows\install-via-wsl.ps1"
|
||||
CreateDirectory "$INSTDIR\data"
|
||||
CreateDirectory "$INSTDIR\data\log"
|
||||
CreateDirectory "$INSTDIR\data\plugins"
|
||||
CreateDirectory "$INSTDIR\data\adapters"
|
||||
|
||||
; homed **不再装到 Windows**:插件体系依赖 fd 继承与统一共享内存区的段内偏移
|
||||
; 解引用,Windows 的句柄模型无法表达(见 cmd/homed/platform_windows.go)。
|
||||
; Windows 侧改为引导到 WSL2,把 **Linux 包**送进发行版里按 Linux 的方式安装。
|
||||
; 所以这里带的是 linux/amd64 的 payload,不是 homed.exe。
|
||||
!if "${HAS_CORE}" == "1"
|
||||
File "..\..\build\initconfig.exe"
|
||||
File "..\..\build\homed.exe"
|
||||
SetOutPath "$PLUGINSDIR\linux-payload"
|
||||
File /r "..\..\build\linux-payload\*.*"
|
||||
SetOutPath "$INSTDIR"
|
||||
!endif
|
||||
|
||||
!if "${HAS_WAITER}" == "1"
|
||||
; waiter 是 CLI 客户端:WSL 侧会装上 Linux 版;Windows 侧仍可保留原生版
|
||||
; (它只是个客户端,不走插件体系)。
|
||||
File "..\..\build\waiter.exe"
|
||||
!endif
|
||||
|
||||
@ -237,11 +250,24 @@ Section "Install" SEC_INSTALL
|
||||
!endif
|
||||
|
||||
!if "${HAS_CORE}" == "1"
|
||||
DetailPrint "初始化配置数据库..."
|
||||
nsExec::Exec '"$INSTDIR\initconfig.exe" -data "$INSTDIR\data" -username "$webuiUsername" -password "$webuiPassword" -apikey "$apiKey"'
|
||||
; 在 WSL2 里安装 homed。凭据(页面上收的那三个)透传进去,避免
|
||||
; 「界面显示一份、config.db 里另一份」导致登录不上。
|
||||
DetailPrint "检测 WSL 并在其中安装 HomeAgent..."
|
||||
nsExec::ExecToStack 'powershell -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\install-via-wsl.ps1" -PayloadDir "$PLUGINSDIR\linux-payload" -ApiKey "$apiKey" -WebUIUser "$webuiUsername" -WebUIPass "$webuiPassword"'
|
||||
Pop $0
|
||||
Pop $1
|
||||
${If} $0 != 0
|
||||
DetailPrint "警告: 数据库初始化可能未成功完成"
|
||||
; 退出码含义见 install-via-wsl.ps1:20/21 是「WSL 或发行版缺失,需要先装」,
|
||||
; 属于可指引的用户动作,不当成安装失败来恐吓人。
|
||||
${If} $0 == 20
|
||||
MessageBox MB_ICONINFORMATION|MB_OK "未检测到 WSL。$\r$\n$\r$\n请在管理员 PowerShell 中执行:$\r$\n wsl --install$\r$\n$\r$\n然后重启 Windows,再重新运行本安装程序。"
|
||||
${ElseIf} $0 == 21
|
||||
MessageBox MB_ICONINFORMATION|MB_OK "WSL 已安装,但还没有发行版。$\r$\n$\r$\n请先执行:$\r$\n wsl --install -d Ubuntu$\r$\n$\r$\n完成首次初始化后再重新运行本安装程序。"
|
||||
${Else}
|
||||
MessageBox MB_ICONEXCLAMATION|MB_OK "WSL 内安装失败(退出码 $0)。$\r$\n$\r$\n可进入 WSL 手动排查:wsl -d Ubuntu$\r$\n安装脚本输出见上方日志。"
|
||||
${EndIf}
|
||||
${Else}
|
||||
DetailPrint "HomeAgent 已在 WSL2 内安装完成"
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ Version: VERSION_PLACEHOLDER
|
||||
Architecture: ARCH_PLACEHOLDER
|
||||
Maintainer: HomeAgent Team <team@homeagent.ai>
|
||||
Installed-Size: INSTALLED_SIZE_PLACEHOLDER
|
||||
Depends: libc6 (>= 2.28)
|
||||
Depends: libc6 (>= 2.28), libstdc++6, libgcc-s1
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Homepage: https://github.com/trueagent/HomeAgent
|
||||
|
||||
@ -3,7 +3,7 @@ Version: VERSION_PLACEHOLDER
|
||||
Architecture: ARCH_PLACEHOLDER
|
||||
Maintainer: HomeAgent Team <team@homeagent.ai>
|
||||
Installed-Size: INSTALLED_SIZE_PLACEHOLDER
|
||||
Depends: libc6 (>= 2.28)
|
||||
Depends: libc6 (>= 2.28), libstdc++6, libgcc-s1
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Homepage: https://github.com/trueagent/HomeAgent
|
||||
|
||||
@ -2,24 +2,63 @@
|
||||
set -e
|
||||
|
||||
SERVICE_NAME="homeagent"
|
||||
SERVICE_FILE="/lib/systemd/system/${SERVICE_NAME}.service"
|
||||
HOMED_BIN="/usr/bin/homed"
|
||||
DATA_DIR="/var/lib/homeagent"
|
||||
SETUP_SH="/usr/lib/homeagent/setup.sh"
|
||||
|
||||
# unit 由本包装到 /etc/systemd/system/,而旧 postinst 只查
|
||||
# /lib/systemd/system/(merged-usr 下等于 /usr/lib/systemd/system,那里没有
|
||||
# 这个文件)——于是 daemon-reload 与 enable **从未执行过**:装完不会开机自启,
|
||||
# 而 postinst 全程无报错。这里三个候选位置都看一下。
|
||||
find_unit() {
|
||||
for p in "/etc/systemd/system/${SERVICE_NAME}.service" \
|
||||
"/usr/lib/systemd/system/${SERVICE_NAME}.service" \
|
||||
"/lib/systemd/system/${SERVICE_NAME}.service"; do
|
||||
if [ -f "$p" ]; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
if [ -f "$HOMED_BIN" ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# 初始化凭据和数据库
|
||||
if [ -x /usr/lib/homeagent/setup.sh ]; then
|
||||
HOMEAGENT_DATA="$DATA_DIR" /usr/lib/homeagent/setup.sh || true
|
||||
# 初始化凭据和数据库。
|
||||
#
|
||||
# 这里不能再用 `|| true` 吞失败:setup.sh 靠 initconfig 写 config.db,
|
||||
# 而 initconfig 曾因 CGO_ENABLED=0 静默空操作(凭据只进了
|
||||
# credentials.txt、没进数据库),用户拿它登录必然失败,安装却一声不响。
|
||||
# 失败必须看得见,并给出可直接执行的补救命令。
|
||||
if [ -x "$SETUP_SH" ]; then
|
||||
if ! HOMEAGENT_DATA="$DATA_DIR" "$SETUP_SH"; then
|
||||
echo "E: homeagent 初始化失败——凭据可能未写入 config.db。" >&2
|
||||
echo "E: 请手动重试:HOMEAGENT_DATA=$DATA_DIR $SETUP_SH" >&2
|
||||
fi
|
||||
else
|
||||
echo "W: 未找到 $SETUP_SH,跳过凭据初始化。" >&2
|
||||
fi
|
||||
|
||||
# 注册 systemd 服务
|
||||
if [ -f "$SERVICE_FILE" ]; then
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
systemctl enable "$SERVICE_NAME" 2>/dev/null || true
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
if find_unit >/dev/null; then
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
systemctl enable "$SERVICE_NAME" 2>/dev/null || true
|
||||
# 首次安装就拉起来,装完即可用;升级时重启以真正加载新二进制
|
||||
# (仅 enable 不会让已在运行的进程换用新文件)。
|
||||
if [ -z "${2:-}" ]; then
|
||||
systemctl start "$SERVICE_NAME" 2>/dev/null || \
|
||||
echo "W: homeagent 服务未能启动,请检查:systemctl status $SERVICE_NAME" >&2
|
||||
else
|
||||
systemctl restart "$SERVICE_NAME" 2>/dev/null || \
|
||||
echo "W: homeagent 服务未能重启,请检查:systemctl status $SERVICE_NAME" >&2
|
||||
fi
|
||||
else
|
||||
echo "W: 未找到 ${SERVICE_NAME}.service,未启用服务。" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
@ -8,16 +8,32 @@ CRED_FILE="${DATA_DIR}/credentials.txt"
|
||||
CONFIG_DB="${DATA_DIR}/config.db"
|
||||
WAITER_CONF="${DATA_DIR}/waiter.yaml"
|
||||
INITCONFIG_BIN="/usr/bin/initconfig"
|
||||
BUNDLED_MODEL_DIR="/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx"
|
||||
MODEL_LINK="${DATA_DIR}/models/chinese-clip-vit-b16-onnx"
|
||||
|
||||
# 如果已经初始化过,跳过
|
||||
# 模型随 server/full 包安装到只读的 /usr/lib;配置默认仍指向 dataDir/models。
|
||||
# 用符号链接把两者接起来,既不复制 754MB,也保持 dataDir 可迁移语义。
|
||||
# 用户已有自定义目录时绝不覆盖;升级时既有链接自然指向新版包内容。
|
||||
if [ -d "$BUNDLED_MODEL_DIR" ]; then
|
||||
mkdir -p "${DATA_DIR}/models"
|
||||
if [ ! -e "$MODEL_LINK" ] && [ ! -L "$MODEL_LINK" ]; then
|
||||
ln -s "$BUNDLED_MODEL_DIR" "$MODEL_LINK"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 如果已经初始化过,只跳过凭据/数据库生成;上面的模型链接仍须在升级时补齐。
|
||||
if [ -f "$CONFIG_DB" ] && [ -f "$CRED_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# 生成随机凭据
|
||||
API_KEY=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)")
|
||||
# 生成随机凭据。
|
||||
#
|
||||
# 允许环境变量覆盖:安装器(包括 Windows 上的 WSL 引导安装)已经在界面上
|
||||
# 向用户收过这些值,若不接受传入就只能两个地方各生成一份,用户看到的那份
|
||||
# 与实际写入 config.db 的那份不一致——那种错会直接表现为「登录不上」。
|
||||
API_KEY="${HOMEAGENT_API_KEY:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)")}"
|
||||
WEBUI_USER="${WEBUI_USER:-admin}"
|
||||
WEBUI_PASS="${WEBUI_PASS:-$(openssl rand -hex 12 2>/dev/null || echo "homeagent")}"
|
||||
|
||||
|
||||
@ -5,11 +5,37 @@ PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
BUILD_DIR="${PROJECT_ROOT}/build"
|
||||
DIST_DIR="${PROJECT_ROOT}/dist/linux"
|
||||
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.8.0")}"
|
||||
|
||||
# git describe 给出的是 v1.0.0-68-gba0b5a1-dirty 这类描述串,它不是合法的包版本:
|
||||
# deb 的 Version 必须以数字开头,rpm 的 Version 不允许 '-'(那是版本/发布的分隔符)。
|
||||
# 以前只有显式传 VERSION=1.0.3 才打得出来,默认路径一跑就死在 dpkg-deb 上——
|
||||
# 而且死在 stage 之后,前面每条日志都是真的,只有最后一个产物没生成。
|
||||
PKG_VERSION="${VERSION#v}"
|
||||
case "$PKG_VERSION" in
|
||||
[0-9]*) ;;
|
||||
*) echo "ERROR: 包版本必须以数字开头(得到 '$VERSION')。请显式设置 VERSION=x.y.z 后重试。" >&2; exit 1 ;;
|
||||
esac
|
||||
PKG_VERSION="$(printf '%s' "$PKG_VERSION" | sed -e 's/-/+/g')"
|
||||
PACKAGE_ROOT="${PROJECT_ROOT}/deploy/packaging/linux"
|
||||
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
|
||||
|
||||
ARCH="${1:-amd64}" # amd64 or arm64
|
||||
|
||||
# server/full 发行包默认带 Chinese-CLIP ONNX 产物与 ONNX Runtime。
|
||||
# 二进制大资产不进 git:发布环境通过这两个目录提供已验证的产物;若缺失,
|
||||
# server/full 打包必须明确失败,不能生成一个「默认启用但装完不能用」的假包。
|
||||
CHINESECLIP_BUNDLE_DIR="${CHINESECLIP_BUNDLE_DIR:-$BUILD_DIR/model-assets/chinese-clip-vit-b16-onnx}"
|
||||
ONNXRUNTIME_ASSET_DIR="${ONNXRUNTIME_ASSET_DIR:-$BUILD_DIR/runtime-assets/$ARCH}"
|
||||
ONNXRUNTIME_LIB="${ONNXRUNTIME_LIB:-$ONNXRUNTIME_ASSET_DIR/libonnxruntime.so}"
|
||||
ONNXRUNTIME_LICENSE="${ONNXRUNTIME_LICENSE:-$ONNXRUNTIME_ASSET_DIR/LICENSE}"
|
||||
ONNXRUNTIME_NOTICES="${ONNXRUNTIME_NOTICES:-$ONNXRUNTIME_ASSET_DIR/ThirdPartyNotices.txt}"
|
||||
|
||||
# 打包 staging 会把 719MB 模型真的复制一份,临时目录必须落在构建目录所在的磁盘,
|
||||
# 不能落在系统临时目录:本机 /tmp 是 9.8GB tmpfs,一次 full 包 staging 就能写满,
|
||||
# 而且失败发生在 cp 进行到一半,报出来是 "No space left on device"——看上去像
|
||||
# 资产/版本有问题,实际只是临时目录选错了文件系统。
|
||||
STAGE_TMP="${BUILD_DIR}/.stage-tmp"
|
||||
|
||||
# electron 官方发布物用 x64/arm64 命名,而 Debian 用 amd64/arm64。
|
||||
# 两者在 arm64 上恰好同名,amd64 上不同——此前缓存查找统一用 TAR_ARCH
|
||||
# (amd64),于是 electron-v*-linux-x64.zip 永远命中不到,amd64 GUI 只能
|
||||
@ -28,6 +54,7 @@ esac
|
||||
|
||||
echo "=== HomeAgent Linux Packager ==="
|
||||
echo "Version: $VERSION"
|
||||
[ "$PKG_VERSION" = "$VERSION" ] || echo "Package: $PKG_VERSION (normalized for deb/rpm)"
|
||||
echo "Arch: $ARCH"
|
||||
echo ""
|
||||
|
||||
@ -89,7 +116,7 @@ restore_syso() {
|
||||
}
|
||||
|
||||
# ensure both are always restored on exit
|
||||
restore_all() { restore_gomod; restore_syso; }
|
||||
restore_all() { restore_gomod; restore_syso; rmdir "$STAGE_TMP" 2>/dev/null || true; }
|
||||
trap restore_all EXIT
|
||||
|
||||
# ---- build Go binaries via existing build.sh ----
|
||||
@ -99,31 +126,29 @@ build_go() {
|
||||
prepare_gomod || true
|
||||
hide_syso
|
||||
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "homed" 2>&1 || {
|
||||
echo "WARNING: homed build failed (CGO/sqlite3 issue). Server/full packages may be incomplete."
|
||||
}
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "waiter" 2>&1 || {
|
||||
echo "WARNING: waiter build failed."
|
||||
}
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "initconfig" 2>&1 || {
|
||||
echo "WARNING: initconfig build failed(包内将缺少首次配置初始化器)。"
|
||||
}
|
||||
|
||||
local suffix="linux_${ARCH}"
|
||||
local homed_bin="$BUILD_DIR/homed_$suffix"
|
||||
local waiter_bin="$BUILD_DIR/waiter_$suffix"
|
||||
local initconfig_bin="$BUILD_DIR/initconfig_$suffix"
|
||||
|
||||
if [ ! -f "$homed_bin" ]; then
|
||||
echo "ERROR: homed binary not found at $homed_bin"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$waiter_bin" ]; then
|
||||
echo "ERROR: waiter binary not found at $waiter_bin"
|
||||
exit 1
|
||||
# 先删旧产物:否则本次构建失败后,残留文件会让「产物存在」判据假绿。
|
||||
rm -f "$homed_bin" "$waiter_bin" "$initconfig_bin"
|
||||
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "homed"
|
||||
test -x "$homed_bin"
|
||||
if ! go version -m "$homed_bin" | grep -Eq 'build[[:space:]]+-tags=.*onnxruntime'; then
|
||||
echo "ERROR: homed 不是 onnxruntime 构建,拒绝打 server/full 包:$homed_bin" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " homed: $homed_bin ($(du -h "$homed_bin" | cut -f1))"
|
||||
echo " waiter: $waiter_bin ($(du -h "$waiter_bin" | cut -f1))"
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "waiter"
|
||||
test -x "$waiter_bin"
|
||||
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "initconfig"
|
||||
test -x "$initconfig_bin"
|
||||
|
||||
echo " homed: $homed_bin ($(du -h "$homed_bin" | cut -f1), onnxruntime)"
|
||||
echo " waiter: $waiter_bin ($(du -h "$waiter_bin" | cut -f1))"
|
||||
echo " initconfig: $initconfig_bin ($(du -h "$initconfig_bin" | cut -f1))"
|
||||
echo ""
|
||||
}
|
||||
|
||||
@ -310,6 +335,8 @@ stage_variant() {
|
||||
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
|
||||
[ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig"
|
||||
stage_setup "$staging"
|
||||
stage_license "$staging"
|
||||
stage_multimodal_assets "$staging"
|
||||
stage_gui "$staging"
|
||||
;;
|
||||
server)
|
||||
@ -318,9 +345,12 @@ stage_variant() {
|
||||
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
|
||||
[ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig"
|
||||
stage_setup "$staging"
|
||||
stage_license "$staging"
|
||||
stage_multimodal_assets "$staging"
|
||||
;;
|
||||
client)
|
||||
cp "$BUILD_DIR/waiter_$suffix" "$staging/usr/bin/waiter"
|
||||
stage_license "$staging"
|
||||
stage_gui "$staging"
|
||||
;;
|
||||
esac
|
||||
@ -355,6 +385,127 @@ stage_setup() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 项目自身的许可:**所有变体**都要带(client 也分发 waiter 与 GUI)。
|
||||
#
|
||||
# deb 按 Debian 惯例给 /usr/share/doc/homeagent/copyright(DEP-5 机器可读格式),
|
||||
# 同时把 LICENSE 全文放进去;rpm 的许可走 fpm 的 --license 元数据。
|
||||
# 与 stage_multimodal_assets 的 licenses/ 分工:那里放**第三方**(模型/运行库)的
|
||||
# 许可全文,这里放本项目自己的。
|
||||
stage_license() {
|
||||
local staging="$1"
|
||||
local docdir="$staging/usr/share/doc/homeagent"
|
||||
mkdir -p "$docdir"
|
||||
cp "$PROJECT_ROOT/LICENSE" "$docdir/LICENSE"
|
||||
cat > "$docdir/copyright" <<'EOF'
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: HomeAgent
|
||||
Source: https://gitcode.com/JianFeeeee/HomeAgent
|
||||
|
||||
Files: *
|
||||
Copyright: HomeAgent contributors
|
||||
License: AGPL-3.0-only
|
||||
This program is free software: you can redistribute it and/or modify it under
|
||||
the terms of the GNU Affero General Public License as published by the Free
|
||||
Software Foundation, version 3 of the License.
|
||||
.
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
details.
|
||||
.
|
||||
You should have received a copy of the GNU Affero General Public License along
|
||||
with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
.
|
||||
The license is AGPL-3.0-only: no later version may be chosen. Note the network
|
||||
clause (§13 Remote Network Interaction) — offering modified versions of this
|
||||
software to users over a network also requires offering them the source.
|
||||
.
|
||||
Full text: /usr/share/doc/homeagent/LICENSE
|
||||
|
||||
Files: usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/*
|
||||
Copyright: OFA-Sys / Chinese-CLIP authors
|
||||
License: Apache-2.0
|
||||
Full text: /usr/share/doc/homeagent/licenses/Chinese-CLIP-Apache-2.0.txt
|
||||
Comment: pre-trained model artifacts; NOT covered by this package's AGPL grant
|
||||
|
||||
Files: usr/lib/homeagent/onnxruntime/*
|
||||
Copyright: Microsoft Corporation
|
||||
License: MIT
|
||||
Full text: /usr/share/doc/homeagent/licenses/ONNX-Runtime-MIT.txt
|
||||
Comment: license texts and third-party notices under licenses/ONNX-Runtime-*
|
||||
EOF
|
||||
chmod 644 "$docdir/LICENSE" "$docdir/copyright"
|
||||
}
|
||||
|
||||
# server/full 的 ONNX 资产。模型与运行库是发行版能力的一部分,不是可选下载:
|
||||
# 只要打 server/full 包,两者缺一就失败。client 包不运行 homed,故不携带。
|
||||
stage_multimodal_assets() {
|
||||
local staging="$1"
|
||||
local model_dst="$staging/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx"
|
||||
local ort_dst="$staging/usr/lib/homeagent/onnxruntime"
|
||||
local licenses="$staging/usr/share/doc/homeagent/licenses"
|
||||
|
||||
if [ ! -d "$CHINESECLIP_BUNDLE_DIR" ]; then
|
||||
echo "ERROR: Chinese-CLIP 产物目录不存在:$CHINESECLIP_BUNDLE_DIR" >&2
|
||||
echo "先运行 scripts/export_chineseclip_onnx.py,再通过 CHINESECLIP_BUNDLE_DIR 指向产物。" >&2
|
||||
return 1
|
||||
fi
|
||||
for f in TextEncoder.onnx VisionEncoder.onnx embed_config.json vocab.txt reference.json SHA256SUMS; do
|
||||
if [ ! -s "$CHINESECLIP_BUNDLE_DIR/$f" ]; then
|
||||
echo "ERROR: Chinese-CLIP 产物缺少或为空:$CHINESECLIP_BUNDLE_DIR/$f" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
if ! (cd "$CHINESECLIP_BUNDLE_DIR" && sha256sum -c SHA256SUMS); then
|
||||
echo "ERROR: Chinese-CLIP SHA256SUMS 校验失败,拒绝打包。" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -s "$ONNXRUNTIME_LIB" ]; then
|
||||
echo "ERROR: ONNX Runtime 不存在:$ONNXRUNTIME_LIB" >&2
|
||||
echo "通过 ONNXRUNTIME_ASSET_DIR 或 ONNXRUNTIME_LIB 指向与目标架构匹配的资产。" >&2
|
||||
return 1
|
||||
fi
|
||||
for notice in "$ONNXRUNTIME_LICENSE" "$ONNXRUNTIME_NOTICES"; do
|
||||
if [ ! -s "$notice" ]; then
|
||||
echo "ERROR: ONNX Runtime 许可证资产缺失:$notice" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
local runtime_desc
|
||||
runtime_desc=$(file -b "$ONNXRUNTIME_LIB")
|
||||
case "$ARCH" in
|
||||
amd64) printf '%s' "$runtime_desc" | grep -qE 'x86-64|x86_64' || {
|
||||
echo "ERROR: ONNX Runtime 架构不是 amd64:$runtime_desc" >&2; return 1; } ;;
|
||||
arm64) printf '%s' "$runtime_desc" | grep -qE 'aarch64|ARM aarch64' || {
|
||||
echo "ERROR: ONNX Runtime 架构不是 arm64:$runtime_desc" >&2; return 1; } ;;
|
||||
esac
|
||||
|
||||
mkdir -p "$model_dst" "$ort_dst" "$licenses"
|
||||
cp -a "$CHINESECLIP_BUNDLE_DIR/." "$model_dst/"
|
||||
install -m 0755 "$ONNXRUNTIME_LIB" "$ort_dst/libonnxruntime.so"
|
||||
|
||||
# 许可证随二进制分发:Chinese-CLIP = Apache-2.0;ONNX Runtime = MIT,
|
||||
# 同时携带其 ThirdPartyNotices(含 MKL/protobuf/zlib 等第三方条款)。
|
||||
cp /usr/share/common-licenses/Apache-2.0 "$licenses/Chinese-CLIP-Apache-2.0.txt"
|
||||
cp "$ONNXRUNTIME_LICENSE" "$licenses/ONNX-Runtime-MIT.txt"
|
||||
cp "$ONNXRUNTIME_NOTICES" "$licenses/ONNX-Runtime-ThirdPartyNotices.txt"
|
||||
cat > "$licenses/MODEL-SOURCES.txt" <<EOF
|
||||
Chinese-CLIP ViT-B/16
|
||||
upstream: https://huggingface.co/OFA-Sys/chinese-clip-vit-base-patch16
|
||||
license: Apache-2.0
|
||||
exported-by: scripts/export_chineseclip_onnx.py
|
||||
dimensions: 512
|
||||
modalities: text,image
|
||||
|
||||
ONNX Runtime
|
||||
upstream: https://github.com/microsoft/onnxruntime
|
||||
license: MIT (see ONNX-Runtime-MIT.txt and ONNX-Runtime-ThirdPartyNotices.txt)
|
||||
EOF
|
||||
|
||||
echo " ONNX assets: model=$(du -sh "$model_dst" | cut -f1) runtime=$(du -h "$ort_dst/libonnxruntime.so" | cut -f1)"
|
||||
}
|
||||
|
||||
# ---- create .deb ----
|
||||
build_deb() {
|
||||
local variant="$1"
|
||||
@ -362,9 +513,9 @@ build_deb() {
|
||||
local deb_dir="${DIST_DIR}/deb"
|
||||
mkdir -p "$deb_dir"
|
||||
|
||||
local pkg_name="homeagent-${variant}_${VERSION}_${DEB_ARCH}.deb"
|
||||
local pkg_name="homeagent-${variant}_${PKG_VERSION}_${DEB_ARCH}.deb"
|
||||
local deb_root
|
||||
deb_root="$(mktemp -d)"
|
||||
deb_root="$(mktemp -d "$STAGE_TMP/deb.XXXXXX")"
|
||||
|
||||
mkdir -p "$deb_root/DEBIAN"
|
||||
|
||||
@ -372,7 +523,7 @@ build_deb() {
|
||||
local installed_size_kb
|
||||
installed_size_kb=$(du -sk "$staging" | cut -f1)
|
||||
|
||||
sed -e "s/VERSION_PLACEHOLDER/$VERSION/g" \
|
||||
sed -e "s/VERSION_PLACEHOLDER/$PKG_VERSION/g" \
|
||||
-e "s/ARCH_PLACEHOLDER/$DEB_ARCH/g" \
|
||||
-e "s/INSTALLED_SIZE_PLACEHOLDER/$installed_size_kb/g" \
|
||||
"$control_file" > "$deb_root/DEBIAN/control"
|
||||
@ -401,13 +552,13 @@ build_tar() {
|
||||
local tar_dir="${DIST_DIR}/tar"
|
||||
mkdir -p "$tar_dir"
|
||||
|
||||
local archive_name="homeagent_${VERSION}_linux_${TAR_ARCH}.tar.gz"
|
||||
local archive_dir="homeagent-${VERSION}-linux-${TAR_ARCH}"
|
||||
local archive_name="homeagent_${PKG_VERSION}_linux_${TAR_ARCH}.tar.gz"
|
||||
local archive_dir="homeagent-${PKG_VERSION}-linux-${TAR_ARCH}"
|
||||
|
||||
# build combined staging
|
||||
local staging
|
||||
staging="$(mktemp -d)"
|
||||
mkdir -p "$staging/usr/bin" "$staging/usr/lib/homeagent"
|
||||
staging="$(mktemp -d "$STAGE_TMP/tar.XXXXXX")"
|
||||
mkdir -p "$staging/usr/bin" "$staging/usr/lib/homeagent" "$staging/etc/systemd/system"
|
||||
|
||||
# copy all available binaries
|
||||
for bin in homed waiter initconfig; do
|
||||
@ -418,6 +569,9 @@ build_tar() {
|
||||
# setup script
|
||||
local setup_src="$PROJECT_ROOT/deploy/packaging/linux/setup.sh"
|
||||
[ -f "$setup_src" ] && cp "$setup_src" "$staging/usr/lib/homeagent/setup.sh"
|
||||
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
|
||||
stage_license "$staging"
|
||||
stage_multimodal_assets "$staging"
|
||||
|
||||
# GUI if available
|
||||
local gui_src="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}"
|
||||
@ -446,7 +600,7 @@ build_rpm() {
|
||||
local rpm_dir="${DIST_DIR}/rpm"
|
||||
mkdir -p "$rpm_dir"
|
||||
|
||||
local pkg_name="homeagent-${variant}-${VERSION}-1.${RPM_ARCH}.rpm"
|
||||
local pkg_name="homeagent-${variant}-${PKG_VERSION}-1.${RPM_ARCH}.rpm"
|
||||
|
||||
# find fpm
|
||||
local fpm_bin="$(command -v fpm 2>/dev/null || true)"
|
||||
@ -495,7 +649,7 @@ build_rpm() {
|
||||
-a "$RPM_ARCH" \
|
||||
--description "HomeAgent ${variant^} package" \
|
||||
--url "https://github.com/trueagent/HomeAgent" \
|
||||
--license "Proprietary" \
|
||||
--license "AGPL-3.0-only" \
|
||||
-C "$staging" \
|
||||
-p "$rpm_dir/$pkg_name" \
|
||||
. 2>&1
|
||||
@ -506,7 +660,7 @@ build_rpm() {
|
||||
main() {
|
||||
local target_arch="$ARCH"
|
||||
|
||||
mkdir -p "$BUILD_DIR"
|
||||
mkdir -p "$BUILD_DIR" "$STAGE_TMP"
|
||||
|
||||
case "$ACTION" in
|
||||
all|build)
|
||||
@ -523,6 +677,10 @@ main() {
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
# 上次成功构建留下的校验和必须在本次开工前删掉:本次若中途失败,脚本直接退出、
|
||||
# 不重算 SHA256SUMS,旧的它会一直躺在 dist 里,看上去像在为这一批残缺产物背书。
|
||||
rm -f "$DIST_DIR/SHA256SUMS"
|
||||
|
||||
for variant in full server client; do
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
@ -530,7 +688,7 @@ main() {
|
||||
echo "=============================================="
|
||||
|
||||
local staging
|
||||
staging=$(mktemp -d)
|
||||
staging=$(mktemp -d "$STAGE_TMP/stage.XXXXXX")
|
||||
stage_variant "$variant" "$staging"
|
||||
|
||||
case "$ACTION" in
|
||||
@ -552,9 +710,26 @@ main() {
|
||||
echo "=== Done! Packages in: $DIST_DIR ==="
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
find "$DIST_DIR" -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.rpm" \) 2>/dev/null | sort | while read -r f; do
|
||||
# 只列**本批**产物:dist/ 会跨多次构建累积,用 find 全目录会让清单/SHA256SUMS
|
||||
# 带上历史版本的文件名——用户下载那种清单后 `sha256sum -c` 必然报缺失。
|
||||
# (v1.2.2 构建时就出现过:清单里混进了 1.2.0/1.2.1 的包名。)按本批版本号过滤。
|
||||
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*${PKG_VERSION}*.deb" -o -name "homeagent_${PKG_VERSION}_*.tar.gz" -o -name "*${PKG_VERSION}*.rpm" \) 2>/dev/null | sort)
|
||||
for f in "${release_files[@]}"; do
|
||||
echo " $(du -h "$f" | cut -f1) $f"
|
||||
done
|
||||
# 全部包生成之后一次计算,避免边打边算漏掉后生成的产物。
|
||||
# 名字用**平铺名**(basename):下载页的附件名就是平铺的,
|
||||
# 清单里若写 ./deb/xxx.deb,用户下载后 `sha256sum -c` 会找不到文件。
|
||||
if [ ${#release_files[@]} -gt 0 ]; then
|
||||
(
|
||||
cd "$DIST_DIR"
|
||||
# 哈希取**真实路径**,标签用**平铺名**:两者不能混(直接对 basename 求哈希会找不到文件)。
|
||||
for f in "${release_files[@]}"; do
|
||||
printf '%s ./%s\n' "$(sha256sum "$f" | awk '{print $1}')" "$(basename "$f")"
|
||||
done | sort -k2 > SHA256SUMS
|
||||
)
|
||||
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS (仅本批 ${#release_files[@]} 个产物,平铺名)"
|
||||
fi
|
||||
}
|
||||
|
||||
main
|
||||
|
||||
259
deploy/packaging/windows/install-via-wsl.ps1
Normal file
259
deploy/packaging/windows/install-via-wsl.ps1
Normal file
@ -0,0 +1,259 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
在 WSL2 中安装 HomeAgent(homed + 插件 + WebUI)。
|
||||
|
||||
.DESCRIPTION
|
||||
Windows 不再提供 homed 的原生安装。原因见 cmd/homed/platform_windows.go:
|
||||
homed 的插件体系依赖「继承的 fd」与「统一共享内存区的段内偏移解引用」,
|
||||
Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属 ABI,
|
||||
而 C ABI 时代三套 ABI 并存正是「改写型插件在某个平台上静默失效」的根因。
|
||||
|
||||
本脚本因此把 Windows 安装流程变成一条引导链:
|
||||
检测 WSL → 必要时引导安装 → 配置(默认版本 2 / systemd)
|
||||
→ 把 **Linux 包** 送进发行版 → 在 WSL 内按 Linux 的方式安装。
|
||||
|
||||
它复用 Linux 侧的安装包与初始化脚本,不另写一套安装逻辑——
|
||||
「WSL 里就是普通 linux/amd64」这一点必须保持成立,否则等于又开了第三个平台。
|
||||
|
||||
.PARAMETER PayloadDir
|
||||
内含 Linux 安装包的目录(安装器把它解到临时目录后传进来)。
|
||||
优先取 *.deb;没有 deb 时回退 *.tar.gz。
|
||||
|
||||
.PARAMETER Distro
|
||||
目标发行版名。省略则用默认发行版;没有发行版时引导安装 Ubuntu。
|
||||
|
||||
.PARAMETER DataDir
|
||||
WSL 内的数据目录。默认 /var/lib/homeagent(与 Linux 原生安装一致)。
|
||||
不建议放 /mnt/c/...:跨文件系统 IO 慢,且 inotify 语义受限。
|
||||
|
||||
.NOTES
|
||||
⚠️ 本脚本在开发环境(Linux)中只能做语法/逻辑审查,**未在真实 Windows + WSL
|
||||
上执行过**。首次使用请逐段核对输出;下面每个阶段都打印了实际执行的命令,
|
||||
便于定位到具体哪一步与预期不符。
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PayloadDir,
|
||||
[string]$Distro = "",
|
||||
[string]$DataDir = "/var/lib/homeagent",
|
||||
[string]$ApiKey = "",
|
||||
[string]$WebUIUser = "",
|
||||
[string]$WebUIPass = "",
|
||||
[switch]$Uninstall
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:StageNo = 0
|
||||
$script:DistroName = $Distro
|
||||
|
||||
function Write-Stage([string]$Text) {
|
||||
$script:StageNo++
|
||||
Write-Host ""
|
||||
Write-Host ("=" * 64) -ForegroundColor DarkGray
|
||||
Write-Host ("[$script:StageNo] $Text") -ForegroundColor Cyan
|
||||
Write-Host ("=" * 64) -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Write-Ok([string]$Text) { Write-Host " ✓ $Text" -ForegroundColor Green }
|
||||
function Write-Warn2([string]$Text) { Write-Host " ! $Text" -ForegroundColor Yellow }
|
||||
function Fail([string]$Text, [string]$Hint = "") {
|
||||
Write-Host ""
|
||||
Write-Host " 安装中止:$Text" -ForegroundColor Red
|
||||
if ($Hint) { Write-Host " $Hint" -ForegroundColor Yellow }
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── 0. 前置检查 ────────────────────────────────────────────────────────────
|
||||
Write-Stage "前置检查"
|
||||
|
||||
$identity = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
# 装 WSL 与写 \\wsl$ 都需要管理员。不静默提权:用户应当看到发生了什么。
|
||||
Fail "需要管理员权限" "请以管理员身份重新运行安装程序。"
|
||||
}
|
||||
Write-Ok "管理员权限"
|
||||
|
||||
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
|
||||
Write-Warn2 "未找到 wsl.exe"
|
||||
Write-Host " homed 不再提供 Windows 原生版本,必须通过 WSL2 运行。"
|
||||
Write-Host ""
|
||||
Write-Host " 在管理员 PowerShell 中执行:" -ForegroundColor Yellow
|
||||
Write-Host " wsl --install" -ForegroundColor White
|
||||
Write-Host " 然后重启 Windows,再重新运行本安装程序。"
|
||||
Write-Host ""
|
||||
Write-Host " (Windows 10 需 2004+ 且启用虚拟机平台;Windows 11 开箱可用)"
|
||||
exit 20
|
||||
}
|
||||
Write-Ok "wsl.exe 可用"
|
||||
|
||||
# ── 1. 检测 WSL 状态与发行版 ───────────────────────────────────────────────
|
||||
Write-Stage "检测 WSL 与发行版"
|
||||
|
||||
# wsl -l -v 在「没有发行版」时返回非零,且输出是 UTF-16LE——直接解析会踩编码坑。
|
||||
# 用 --status 取默认发行版,再单独枚举列表。
|
||||
$distros = @()
|
||||
try {
|
||||
$raw = (& wsl.exe -l -q 2>$null | Out-String)
|
||||
$distros = $raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
|
||||
} catch {
|
||||
$distros = @()
|
||||
}
|
||||
|
||||
if ($distros.Count -eq 0) {
|
||||
Write-Warn2 "WSL 已安装,但没有任何发行版"
|
||||
Write-Host ""
|
||||
Write-Host " 请先安装发行版(推荐 Ubuntu):" -ForegroundColor Yellow
|
||||
Write-Host " wsl --install -d Ubuntu" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host " 首次启动 Ubuntu 会要求创建 Linux 用户名与密码,完成后重新运行本安装程序。"
|
||||
exit 21
|
||||
}
|
||||
|
||||
if ($script:DistroName -eq "") {
|
||||
try {
|
||||
$script:DistroName = (& wsl.exe --status 2>$null | Select-String -Pattern "Default Distribution" |
|
||||
ForEach-Object { ($_ -split ":")[1].Trim() })
|
||||
} catch { }
|
||||
if (-not $script:DistroName) { $script:DistroName = $distros[0] }
|
||||
}
|
||||
Write-Ok "发行版:$($script:DistroName)(共 $($distros.Count) 个:$($distros -join ', '))"
|
||||
|
||||
# ── 2. 确保是 WSL2 ─────────────────────────────────────────────────────────
|
||||
Write-Stage "确保使用 WSL2"
|
||||
|
||||
# WSL1 没有真正的 Linux 内核、没有 systemd,且在共享内存/事件语义上与 WSL2 不同。
|
||||
# homed 依赖 eventfd + mmap 语义,WSL1 会以难以诊断的方式失败,因此显式要求 WSL2。
|
||||
try {
|
||||
$verLine = (& wsl.exe -l -v 2>$null | Out-String) -split "`r?`n" |
|
||||
Where-Object { $_ -match [regex]::Escape($script:DistroName) } | Select-Object -First 1
|
||||
if ($verLine -match "\b1\b") {
|
||||
Write-Warn2 "该发行版当前是 WSL1,正在升级为 WSL2 ..."
|
||||
& wsl.exe --set-version $script:DistroName 2
|
||||
if ($LASTEXITCODE -ne 0) { Fail "WSL2 升级失败" "可手动执行:wsl --set-version $($script:DistroName) 2" }
|
||||
}
|
||||
} catch { }
|
||||
& wsl.exe --set-default-version 2 | Out-Null
|
||||
Write-Ok "已使用 WSL2"
|
||||
|
||||
# ── 3. 准备 Linux 包 ───────────────────────────────────────────────────────
|
||||
Write-Stage "准备 Linux 安装包"
|
||||
|
||||
$deb = Get-ChildItem -Path $PayloadDir -Filter "*.deb" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
$tar = Get-ChildItem -Path $PayloadDir -Filter "*.tar.gz" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($deb) {
|
||||
$pkg = $deb.FullName
|
||||
$pkgKind = "deb"
|
||||
} elseif ($tar) {
|
||||
$pkg = $tar.FullName
|
||||
$pkgKind = "tar"
|
||||
} else {
|
||||
Fail "在 $PayloadDir 下既没找到 .deb 也没找到 .tar.gz" "安装器应把 Linux 包解到该目录。"
|
||||
}
|
||||
Write-Ok "使用 $(Split-Path $pkg -Leaf)($pkgKind)"
|
||||
|
||||
# ── 4. 把包送进 WSL ────────────────────────────────────────────────────────
|
||||
Write-Stage "把安装包送入 WSL"
|
||||
|
||||
# 走 /mnt/c 而不是 \\wsl$:前者是 WSL 稳定的对外通道,且不需要额外的 UNC 权限;
|
||||
# 后者在某些 Windows 版本上对 Program Files 路径有重定向限制。
|
||||
$winPath = (Resolve-Path $pkg).Path
|
||||
$mntPath = "/mnt/" + $winPath.Substring(0, 1).ToLower() + ($winPath.Substring(2) -replace '\\', '/')
|
||||
Write-Host " 源:$mntPath"
|
||||
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc "mkdir -p /tmp/homeagent-install"
|
||||
if ($LASTEXITCODE -ne 0) { Fail "无法在 WSL 内创建临时目录" "确认发行版可正常启动:wsl -d $($script:DistroName)" }
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc "cp '$mntPath' /tmp/homeagent-install/"
|
||||
if ($LASTEXITCODE -ne 0) { Fail "复制安装包失败" }
|
||||
Write-Ok "已送到 /tmp/homeagent-install/"
|
||||
|
||||
# ── 5. 在 WSL 内安装 ───────────────────────────────────────────────────────
|
||||
Write-Stage "在 WSL 内安装 homed"
|
||||
|
||||
# 凭据经环境变量传给 setup.sh(它已支持 HOMEAGENT_API_KEY / WEBUI_USER / WEBUI_PASS)。
|
||||
# 不传的话就会「界面显示一份、config.db 里另一份」,用户直接登录不上。
|
||||
$credEnv = ""
|
||||
if ($ApiKey) { $credEnv += "export HOMEAGENT_API_KEY='$ApiKey'; " }
|
||||
if ($WebUIUser) { $credEnv += "export WEBUI_USER='$WebUIUser'; " }
|
||||
if ($WebUIPass) { $credEnv += "export WEBUI_PASS='$WebUIPass'; " }
|
||||
|
||||
# 安装逻辑复用 Linux 侧:deb 走 apt(postinst 会调用 setup.sh 生成凭据与 config.db),
|
||||
# tar 则解包到你同一套布局再执行同一份 setup.sh。刻意不在这里重写安装步骤——
|
||||
# 「WSL 里就是普通 linux/amd64」必须保持成立,否则等于又开了第三个平台。
|
||||
if ($pkgKind -eq "deb") {
|
||||
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
|
||||
set -e
|
||||
$credEnv
|
||||
export HOMEAGENT_DATA='$DataDir'
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '$inWslPkg'
|
||||
"@
|
||||
} else {
|
||||
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
|
||||
set -e
|
||||
$credEnv
|
||||
mkdir -p /opt/homeagent /tmp/homeagent-extract
|
||||
tar -xzf '$inWslPkg' -C /tmp/homeagent-extract
|
||||
cd /tmp/homeagent-extract
|
||||
# 与 deb 完全相同的布局:/usr/bin/homed + /usr/lib/homeagent/setup.sh。
|
||||
# 两套安装若落到不同路径,之后的升级/排障就会出现「按文档找不到文件」。
|
||||
install -m 0755 homed /usr/bin/homed
|
||||
install -m 0755 waiter /usr/bin/waiter
|
||||
[ -f initconfig ] && install -m 0755 initconfig /usr/bin/initconfig
|
||||
if [ -f homeagent.service ]; then
|
||||
install -m 0644 homeagent.service /etc/systemd/system/homeagent.service
|
||||
fi
|
||||
mkdir -p /usr/lib/homeagent
|
||||
if [ -f setup.sh ]; then install -m 0755 setup.sh /usr/lib/homeagent/setup.sh; fi
|
||||
export HOMEAGENT_DATA='$DataDir'
|
||||
if [ -x /usr/lib/homeagent/setup.sh ]; then bash /usr/lib/homeagent/setup.sh; fi
|
||||
"@
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "WSL 内安装失败(退出码 $LASTEXITCODE)" "可进入 WSL 手动排查:wsl -d $($script:DistroName)"
|
||||
}
|
||||
Write-Ok "安装完成"
|
||||
|
||||
# ── 6. 启动与自启 ──────────────────────────────────────────────────────────
|
||||
Write-Stage "启动 homed 与自启配置"
|
||||
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
|
||||
if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files 2>/dev/null | grep -q homeagent; then
|
||||
systemctl enable homeagent 2>/dev/null || true
|
||||
systemctl restart homeagent
|
||||
echo ' ✓ systemd 服务 homeagent 已启动并设为自启'
|
||||
else
|
||||
# 没有 systemd(WSL2 默认可能没开):用 nohup 起,并把自启交给 Windows 侧的计划任务。
|
||||
pkill -f '/usr/bin/homed' 2>/dev/null || true
|
||||
nohup /usr/bin/homed -data '$DataDir' > /var/log/homeagent-boot.log 2>&1 &
|
||||
echo ' ✓ 已用 nohup 启动(未检测到 systemd)'
|
||||
fi
|
||||
"@
|
||||
|
||||
$creds = & wsl.exe -d $script:DistroName -u root -- bash -lc "cat '$DataDir/credentials.txt' 2>/dev/null || true"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host " HomeAgent 已在 WSL2($($script:DistroName))内安装完成" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host " WebUI:http://localhost:8080" -ForegroundColor White
|
||||
Write-Host " (WSL2 会把 WSL 内的端口映射到 Windows 的 localhost,无需额外配置)"
|
||||
Write-Host ""
|
||||
if ($creds) {
|
||||
Write-Host " 初始凭据(也保存在 WSL 内 $DataDir/credentials.txt):" -ForegroundColor Yellow
|
||||
Write-Host $creds
|
||||
} else {
|
||||
Write-Host " 未读到凭据文件,请进入 WSL 检查:cat $DataDir/credentials.txt" -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host " 常用操作(在 PowerShell 中):"
|
||||
Write-Host " 进入 WSL : wsl -d $($script:DistroName)"
|
||||
Write-Host " 查看日志 : wsl -d $($script:DistroName) -u root -- journalctl -u homeagent -f"
|
||||
Write-Host " 重启服务 : wsl -d $($script:DistroName) -u root -- systemctl restart homeagent"
|
||||
Write-Host ""
|
||||
Write-Host " 注意:WSL 实例不会随 Windows 启动而自动拉起。若需要开机自启,"
|
||||
Write-Host " 可创建一个登录时触发的计划任务执行:"
|
||||
Write-Host " wsl -d $($script:DistroName) -u root -- systemctl start homeagent"
|
||||
exit 0
|
||||
20
deploy/systemd/embed-sidecar.service
Normal file
20
deploy/systemd/embed-sidecar.service
Normal file
@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Jina v5-omni-nano Embedding Sidecar for HomeAgent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/home/newqqagent
|
||||
ExecStart=/usr/local/bin/python3 /home/program/TrueAgent/scripts/embed_sidecar.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=JINA_MODEL_DIR=/home/newqqagent/models/jina-v5-omni-nano
|
||||
Environment=JINA_PORT=18999
|
||||
Environment=JINA_DIMENSION=768
|
||||
Environment=OMP_NUM_THREADS=8
|
||||
Environment=MKL_NUM_THREADS=8
|
||||
Environment=TOKENIZERS_PARALLELISM=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
124
docs/embedding-comparison.md
Normal file
124
docs/embedding-comparison.md
Normal file
@ -0,0 +1,124 @@
|
||||
# 检索方案对比报告(2026-09-09)
|
||||
## 测试数据
|
||||
- 文档库:492 篇生产文档(过滤 108 条健康检查测试文档)
|
||||
- 媒体库:3 张生产图片(验证码、新闻截图、深色模式备忘录)
|
||||
- 文本查询:10 组(精确匹配、语义、跨语言、模糊表达)
|
||||
- 媒体查询:6 组(中文/英文查图片,3 张图片各 2 条)
|
||||
|
||||
---
|
||||
|
||||
## 一、文本检索对比(文档库)
|
||||
|
||||
| 方案 | Hit@1 | Hit@5 | MRR | 平均延迟 |
|
||||
|------|-------|-------|-----|----------|
|
||||
| TF-IDF | 3/10 | 7/10 | 0.457 | 0.3ms |
|
||||
| fastText(200k 中文+378k 英文) | 5/10 | 5/10 | 0.530 | 8.3ms |
|
||||
| TF-IDF + fastText RRF | 4/10 | 7/10 | 0.552 | 12.3ms |
|
||||
| **Jina v5-omni-nano** | **8/10** | **10/10** | **0.900** | **39.9ms** |
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. **Jina 的优势来自"短语语义"能力**:
|
||||
- "邮件代理是否已经成功接入" → TF-IDF rank 5,Jina rank 1
|
||||
- "升级安装 QQ 插件包" → fastText rank 169,Jina rank 1(margin +0.30)
|
||||
- "我所在城市的天气预报" → fastText rank 44,Jina rank 1
|
||||
- "聊天输入区域文字多了会不会自动增高" → TF-IDF rank 1,Jina rank 1(margin +0.33)
|
||||
|
||||
2. **TF-IDF 在精确匹配上不可替代**:
|
||||
- "长期文档记忆功能是否健康" → TF-IDF rank 3,Jina rank 1
|
||||
- "重新加载全部扩展组件" → TF-IDF rank 0(完全未命中),Jina rank 2
|
||||
- TF-IDF 的 Hit@5 70% 证明精确关键词召回仍有价值
|
||||
|
||||
3. **RRF 融合反而变差**:
|
||||
- TF-IDF+fastText RRF MRR=0.552,低于 Jina 单路 0.900
|
||||
- 原因:两种稀疏向量的排序在语义查询上高度重叠,RRF 无法弥补各自短板
|
||||
|
||||
---
|
||||
|
||||
## 二、图片检索对比(同 3 张图片,6 条查询)
|
||||
|
||||
| 方案 | Hit@1 | MRR | 平均 margin |
|
||||
|------|-------|-----|-------------|
|
||||
| CLIP ViT-B/32 | 4/6 | 0.806 | -0.008(负值!) |
|
||||
| Jina v5-omni-nano | 4/6 | 0.833 | +0.024 |
|
||||
|
||||
### 逐条对比
|
||||
|
||||
| 查询 | CLIP rank | CLIP margin | Jina rank | Jina margin |
|
||||
|------|-----------|-------------|-----------|-------------|
|
||||
| 验证码图片(中) | 1 | +0.027 | 1 | +0.036 |
|
||||
| 验证码图片(英) | 1 | +0.063 | 1 | +0.077 |
|
||||
| 新闻截图(中) | 6 | -0.091 | 2 | -0.064 |
|
||||
| 新闻截图(英) | 1 | +0.008 | 2 | -0.028 |
|
||||
| 备忘录截图(中) | 3 | -0.045 | 1 | +0.045 |
|
||||
| 备忘录截图(英) | 1 | +0.051 | 1 | +0.079 |
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. **中文文本→图片**:Jina 明显优于 CLIP(MRR 0.833 vs 0.611)
|
||||
- CLIP 中文查询余弦可低至 -0.076(完全反直觉)
|
||||
- Jina 最差也是 +0.045,正样本始终高于负样本
|
||||
|
||||
2. **新闻截图是共同弱点**:
|
||||
- CLIP 和 Jina 都被"深色模式备忘录"抢走新闻截图的排序
|
||||
- 原因:新闻截图的文字描述含"深色"、"备忘录"等词,与备忘录图片的视觉特征重叠
|
||||
- 这是描述质量 vs 视觉特征的竞争,不是模型问题
|
||||
|
||||
3. **margin 的实际意义**:
|
||||
- CLIP 的平均 margin = -0.008(负值意味着正样本平均不如负样本)
|
||||
- Jina 的平均 margin = +0.024(正样本始终略高于负样本)
|
||||
- 但两者的 margin 都很小(< 0.1),生产环境仍需阈值校准
|
||||
|
||||
---
|
||||
|
||||
## 三、延迟与资源
|
||||
|
||||
| 方案 | 单次查询延迟 | 索引构建 | 内存 |
|
||||
|------|-------------|----------|------|
|
||||
| TF-IDF | 0.3ms | <1s | ~50MB |
|
||||
| fastText | 8.3ms | <1s | ~200MB |
|
||||
| CLIP ONNX | 26ms | N/A | ~600MB |
|
||||
| Jina v5-omni CPU | 39.9ms | 78s(492篇) | ~4GB |
|
||||
|
||||
---
|
||||
|
||||
## 四、结论与建议
|
||||
|
||||
### 核心判断
|
||||
|
||||
| 维度 | TF-IDF/fastText | CLIP | Jina v5-omni |
|
||||
|------|-----------------|------|--------------|
|
||||
| 文本精确匹配 | ★★★★★ | N/A | ★★★★ |
|
||||
| 文本语义检索 | ★★ | N/A | ★★★★★ |
|
||||
| 中文文本→图片 | 无能力 | ★ | ★★★★ |
|
||||
| 英文文本→图片 | 无能力 | ★★★ | ★★★★ |
|
||||
| 图片→图片 | 无能力 | ★★★ | ★★★★ |
|
||||
| 多语言统一空间 | 无能力 | 有限 | ★★★★★ |
|
||||
| 延迟 | ★★★★★ | ★★★ | ★★ |
|
||||
|
||||
### 架构建议
|
||||
|
||||
1. **保留 TF-IDF 作为精确召回的一级通道**:
|
||||
- 0.3ms 延迟不可替代
|
||||
- Hit@5 70% 证明在关键词匹配场景仍有价值
|
||||
- 特别是"插件安装"、"设备查询"这类精确操作指令
|
||||
|
||||
2. **用 Jina 替换 fastText + CLIP 的稠密通道**:
|
||||
- Jina 单路 MRR=0.90,超过 fastText+CLIP 融合
|
||||
- 统一空间消除三条通道的维护成本
|
||||
- 中文文本→图片从"无法检索"提升到"可检索"
|
||||
|
||||
3. **两路融合:TF-IDF + Jina RRF**(而非 TF-IDF + fastText RRF):
|
||||
- TF-IDF 精确匹配 + Jina 语义覆盖
|
||||
- RRF 避免跨空间分数归一化问题
|
||||
- 预期 MRR > 0.90(精确匹配补 Jina 的语义盲区)
|
||||
|
||||
4. **图片检索仍需阈值校准**:
|
||||
- Jina 的 margin 平均 +0.024,生产环境需设置合理阈值
|
||||
- 建议:用真实正负样本对重新标定,而非沿用 CLIP 的 0.20 阈值
|
||||
|
||||
### 下一步
|
||||
|
||||
- 实现 TF-IDF + Jina RRF 融合,验证 MRR 是否能突破 0.90
|
||||
- 用更多生产图片标定 Jina 的图片检索阈值
|
||||
- 测试 fastText 词嵌入是否可以完全被 Jina 文本编码替代(L0 相关性计算)
|
||||
@ -130,26 +130,66 @@ main ──────────────── E ────────
|
||||
|
||||
---
|
||||
|
||||
## 三、当前分支对齐(2026-09-04 执行)
|
||||
### 7. 开发者文档的发布归属(以 rel 分支的形态为准)
|
||||
|
||||
**规则:面向使用者的开发者文档,先在对应的 `release/vX.Y.x` 上修正成「这一版的实际行为」,
|
||||
再 cherry-pick 合入 `main`。**(文档属 §二.3 所列的发布分支允许事项之一)
|
||||
|
||||
为什么不能直接改 main:
|
||||
|
||||
- `main` 的语义是**下一个未发布版本**(§二.1)。在那儿写的文档要么描述尚未发布的行为,
|
||||
要么与当前 rel 的实际行为**相反**,而文档的读者(包括模型自身)会把它当事实。
|
||||
- `assets/docs/**` 会**随发行包分发并在 WebUI 里被阅读**——它服务的是“这一版”,不是“下一版”。
|
||||
- 版本号、工具名、机制的有无都是**随版变动的**:同一个文件在两个分支上就应该是两种口径。
|
||||
|
||||
做法:
|
||||
|
||||
```bash
|
||||
git switch release/v1.2.x
|
||||
# 按这一版口径修改:版本号、当前工具名(hmapdev)、已移除机制不再写成现行
|
||||
# ... 编辑 assets/docs/**、README{,_EN}.md、docs/zh/** ...
|
||||
git commit -m "docs: 按 v1.2.x 口径修正 …"
|
||||
git switch main && git cherry-pick <sha> # 遵守 §三:只 pick,不 merge
|
||||
```
|
||||
|
||||
`main` 上若需要描述“下一版才有的行为”,必须显式标注(如「(下一版)」或附版本号),
|
||||
不得让读者以为它已发布。
|
||||
|
||||
**反例(本仓真实踩过,均为“文档当成事实后反向误导”)**:
|
||||
|
||||
| 现象 | 后果 |
|
||||
|---|---|
|
||||
| 人格卡写死 `v0.9.0(C ABI v2)` | 内核接口/日志报 1.2.0,agent 却向用户自述旧版本(且该机制 v1.0.0 已删除) |
|
||||
| 架构文档在 1.2.0 后仍把“描述式索引 + 引用计数 GC”写成现行机制 | 读者按已删除的设计理解现行行为 |
|
||||
| README 停在 v1.1.1 并描述已被删除的机制 | 同上 |
|
||||
|
||||
配套硬约束:**任何“模型或用户会当作事实”的文本,都不得写死版本号**——
|
||||
要么用 `meta.Version` 插值,要么要求读运行时快照,并用测试钉住
|
||||
(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
|
||||
|
||||
---
|
||||
|
||||
## 三、当前分支对齐(2026-09-12 更新)
|
||||
|
||||
### 主仓(TrueAgent)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | 含全部 hotfix(逐个 cherry-pick),`meta.Version` = 下一个未发布中版本(现为 `1.2.0`) | ✅ 保持 |
|
||||
| `release/v1.0.x` | 承载 v1.0.0 / v1.0.1 / v1.0.3 全部 tag | ✅ **由 `release/v1.0.1` 重命名而来**(2026-09-04) |
|
||||
| `release/v1.0.0` | `9b92a04`,已被 1.0.x 线完全包含(`merge-base --is-ancestor` 验证通过) | 🗑️ **已删除**(本地 + 远端),tag `v1.0.0` 保留全部历史 |
|
||||
| `release/v1.0.1` | 旧 patch 号命名 | 🗑️ **已重命名为 `release/v1.0.x`**(远端旧名删除) |
|
||||
| `feature/memory-media` | 记忆系统媒体(多模态)支持,进行中 | ⏳ 完成后合回 main 并删除 |
|
||||
| `feature/plugin-proc-migration` | 已合入 main(`525aa1f`) | ⏳ 待删(规范要求合回后删除) |
|
||||
| `release/v1.1.x` | 承载 v1.1.0 / v1.1.0-beta.1 / v1.1.1 全部 tag | ✅ 1.1 线的唯一发布分支 |
|
||||
| `main` | 含全部回流修复;`meta.Version` = 下一个未发布中版本(现为 `1.3.0`) | ✅ 保持 |
|
||||
| `release/v1.2.x` | **本条发布线**,`meta.Version` = `1.2.0`,vendored SDK 定版 `1.2.0`;已载入两个发布前修复(GUI 输出目录、知识库同名覆盖) | 🆕 2026-09-12 从 main 切出;**尚无 tag** |
|
||||
| `release/v1.1.x` | 承载 `v1.1.0-beta.1` / `v1.1.0` / `v1.1.1` | 📦 已退役(§2.6:下个中版本发布即退役),保留供追溯 |
|
||||
| `release/v1.0.x` | 承载 1.0.x 全部 tag | 📦 保留 |
|
||||
| `feature/multimodal-embedding` | 已合入 main(`eb4762a`,43 提交,`--no-ff`) | ⏳ 待删(删远端分支需用户确认,§执行守则 3) |
|
||||
|
||||
> `feature/memory-media`、`feature/plugin-proc-migration` 均已从远端删除(旧表里的待删项已处理)。
|
||||
|
||||
### SDK 仓(homeagent-sdk)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 `1.2.0`) | ✅ 保持 |
|
||||
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心 `release/v1.1.x` 对应 |
|
||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 **`1.2.0`**)——SDK **不跟 beta 发版**(§七.2),1.2.0 要等核心的**正式** tag 才定版(§七.3),在那之前路牌不得越过它。此阶段与核心 main(`1.3.0`)**故意不对称**,详见 §七.4 | ✅ 保持 |
|
||||
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心对应 |
|
||||
| `release/v1.2.x` | **尚未创建** | ⏳ 随核心**正式** tag 一起建(§七.3:分支上把版本定为 `1.2.0` 再打 `v1.2.0`;beta 阶段不发 SDK) |
|
||||
| `release/v1.0.0` | 旧 patch 号命名形态,内容已被 main 完全包含 | 📦 保留(供追溯 1.0 线构建) |
|
||||
|
||||
### 1.0.x 发布线 tag 历史
|
||||
@ -174,6 +214,18 @@ main ──────────────── E ────────
|
||||
> 而 semver 预发布语义里 `1.1.0-beta.1 < 1.1.0`。这是「一条发布分支 + tag 区分通道」的
|
||||
> 已知代价:beta 是为验证**打包链路**而补打的,不代表源码更旧。发布说明里已注明。
|
||||
|
||||
### 1.2.x 发布线 tag 历史
|
||||
|
||||
| tag | 提交 | 通道 | SDK | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `v1.2.0-beta.1` | `215804c` | beta | 不发 | 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存(RPC 协议 **2**,与 1.x 不兼容)。按 §七.2,beta 不伴随 SDK 发版 |
|
||||
| (正式 tag 待打) | — | — | — | 试运行 beta 无回退问题后打 `v1.2.0`,并同步 SDK 仓 `release/v1.2.x` + `v1.2.0` |
|
||||
|
||||
> 1.2.x 与存量插件**不兼容**:RPC 协议升到 2(fd3 布局改变),存量外部插件必须用
|
||||
> 新版 plugindev 重编为 `plugin.bin`——**不支持滚动升级**,内核与插件须同批重建、同批安装。
|
||||
> 按 §2.4,跳级直发正式版需在发布说明里列明「单点修复 / 反向验证 / 全类审计」三项;
|
||||
> 本次改动面大(统一多模态向量空间 + 协议 2 + 数据面全量迁移),不满足跳级条件。
|
||||
|
||||
---
|
||||
|
||||
## 四、现网部署与版本对应(运维纪律)
|
||||
@ -300,3 +352,15 @@ git branch -d release/v1.0.x # tag 已保存历史,
|
||||
两仓的 `main` 都遵守 §2.1:`meta.Version` 是**下一个未发布中版本**。
|
||||
所以在 1.1.x 线发布期间,两仓 main 上的值都是 `1.2.0`——它标记「main 正在积攒 1.2 的东西」,
|
||||
而不是「1.2.0 已经存在」。已发布的版本号一律看对应 `release/vX.Y.x` 分支与 tag。
|
||||
|
||||
**但两仓「同步推进」是有条件的**(这一点曾导致误判,现补写清楚):
|
||||
推进的前提是**该中版本已经正式发布过**。具体到当前:
|
||||
|
||||
- 核心:切出 `release/v1.2.x` 后,1.2.0 就归发布线所有,main 立即推进到 `1.3.0`;
|
||||
**即使 1.2.0 目前只有 beta tag**(beta 不上现网,但发布线已占住这个号)。
|
||||
- SDK:因为 §七.2 **beta 不发 SDK**,SDK 1.2.0 要等核心的**正式** tag 才定版、
|
||||
建 `release/v1.2.x`、打 `v1.2.0`(§七.3)。在那之前,SDK 的「下一个未发布中版本」
|
||||
仍然是 `1.2.0`,其 main 不得越过它。
|
||||
|
||||
→ 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**,
|
||||
不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。)
|
||||
|
||||
455
docs/zh/multimodal-space.md
Normal file
455
docs/zh/multimodal-space.md
Normal file
@ -0,0 +1,455 @@
|
||||
# 统一多模态向量空间
|
||||
|
||||
核心不绑定任何具体模型:它按 provider 名从公共注册表(`pkg/embedding`)打开一个
|
||||
向量空间。仓库内自带两个:
|
||||
|
||||
| provider | 模态 | 维度 | 实测常驻 | 许可 | 适用 |
|
||||
|---|---|---|---|---|---|
|
||||
| `chineseclip` | text + image | 512 | **1.15 GB** | Apache-2.0 | 默认(内存受限 / 中文图文) |
|
||||
| `qwen3vl` | text + image(视频已实现未纳入契约) | 2048 | 9.4 GB | Apache-2.0 | 内存充足 / 需要更强文本语义或视频 |
|
||||
| `http` | 由外部服务决定 | 由外部服务决定 | 由外部服务决定 | — | 侧车部署(如 jina-v5-omni-nano,注意其 CC BY-NC 许可) |
|
||||
|
||||
下面第一节是 Qwen3-VL(2048 维,最强但最重),第二节是 Chinese-CLIP(512 维,
|
||||
默认推荐)。两者互斥启用,改配置后重启生效。
|
||||
|
||||
文本、图像、**视频帧** 在同一模型、同一维度、同一 fingerprint 空间里被编码。
|
||||
记忆系统用它做三件事:多模态图记忆的跨模态召回、multimodal doc 的向量融合、
|
||||
multimodal context 的相关性裁剪/淘汰。
|
||||
|
||||
统一空间取代了此前「把图片交给视觉模型生成文字描述、再按描述检索」的做法。
|
||||
那条链路有三个致命缺陷:描述是异步生成的(未生成前媒体等于不存在)、语义检索
|
||||
实际上只搜描述文字、图库里的「媒体节点」只是描述文本的投影而不是媒体本身。
|
||||
**不要再引入任何描述式索引。**
|
||||
|
||||
## 一、产物与获取
|
||||
|
||||
产物约 8 GB(含外部权重),**不进仓库**;用导出脚本自动拉取模型并导出:
|
||||
|
||||
```bash
|
||||
# 默认导出 图像 + 视频 G=2,3,4(即 4/6/8 帧)
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py \
|
||||
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
|
||||
# 只要 4 帧的视频档(省磁盘、省内存)
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py --video-groups 2 --out ...
|
||||
|
||||
# 已下载过模型:跳过拉取
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py \
|
||||
--model-dir /path/to/Qwen3-VL-Embedding-2B \
|
||||
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
|
||||
# 参考向量默认直接写进产物目录(<out>/qwen_reference.json),无需额外参数
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py --model-dir ... --out ...
|
||||
```
|
||||
|
||||
国内镜像:导出脚本沿用 `huggingface_hub` 的约定,直接 `export HF_ENDPOINT=https://hf-mirror.com` 即可。
|
||||
依赖:`torch`(CPU 版即可)、`transformers>=4.57`、`onnx`、`onnxruntime`、`pillow`、`numpy`,
|
||||
以及可选的 `huggingface_hub` / `modelscope`。显存不需要,内存建议 ≥ 16 GB(FP32 加载约 8 GB)。
|
||||
|
||||
导出脚本**会清空 --out 目录**后重写,避免旧图/旧外部权重污染 fingerprint
|
||||
(fingerprint 变化会触发一次无意义的全量向量重算)。因此不要直接覆盖线上正在使用的目录,
|
||||
先导出到新目录再切换。
|
||||
|
||||
### 产物契约(Go 侧按此读取)
|
||||
|
||||
| 文件 | 输入 | 输出 |
|
||||
|---|---|---|
|
||||
| `TokenEmbedding.onnx` | `input_ids` int64 `[1,seq]` | `hidden` float `[1,seq,2048]` |
|
||||
| `Transformer.onnx` | `hidden`、`deepstack_0/1/2` `[1,seq,2048]`、`rotary_cos/sin` `[1,seq,128]`、`causal_mask` `[1,1,seq,seq]` | `embedding` `[1,2048]` |
|
||||
| `Vision.onnx(+.data)` | `pixel_values` `[2304,1536]` | `deepstack_feature_0/1/2`、`vision_hidden_states` `[576,2048]` |
|
||||
| `Vision_g{N}.onnx` | `pixel_values` `[N×2304,1536]` | 同上,`[N×576,2048]` |
|
||||
|
||||
外加 `tokenizer.json`、`tokenizer_config.json`、`chat_template.jinja`、`embed_config.json`、
|
||||
`qwen_reference.json`。
|
||||
|
||||
`Vision.onnx` 是图像(单时间组);`Vision_g{N}.onnx` 是视频(N 个时间组 = 2N 帧)。
|
||||
**没有 `Vision_g1.onnx`**——单组就是图像那张。
|
||||
|
||||
三段只是部署形式,不是三个向量空间:图文共用同一 token embedding、同一 28 层
|
||||
Transformer、同一 last-token 池化。RoPE 与视觉特征散射故意留在 Go 计算,
|
||||
因为旧式 tracer 会把 `seq=598 / visual=576` 烘焙进图里——签名上写着 dynamic
|
||||
axis,实际却只能用导出的那个长度运行。
|
||||
|
||||
### ⚠️ max_length 必须按最大视频档推导
|
||||
|
||||
`embed_config.json` 的 `max_length` 是**整条序列**的上限,包含视觉占位符:
|
||||
图像只需 598 token(1×576 + 模板),而视频是 G×576——G=2 就要 1190,G=4 要 2342。
|
||||
沿用图像的 1024 会让处理器静默截断,然后在 transformers 内部报
|
||||
`Mismatch in video token count between text and input_ids`。
|
||||
导出脚本因此用 `max_length_for(video_groups) = max(1024, max(G)×576 + 256)` 自动推导,
|
||||
并在构造视觉输入后显式断言视觉 token 数,把错误提前到导出阶段。
|
||||
|
||||
### 导出脚本自检(不可省)
|
||||
|
||||
脚本内部跑两道校验,任一道 cos < 0.999999 就以非零码退出:
|
||||
|
||||
1. 分段 PyTorch(三段组合)对比完整模型前向;
|
||||
2. 用 onnxruntime 跑**导出后**的三段图,再对比完整模型前向。
|
||||
|
||||
「能加载」不等于「算得对」:形状错、输入名错、池化位置错的图都能正常 load。
|
||||
|
||||
## 一·补、text+image 默认空间:Chinese-CLIP ViT-B/16
|
||||
|
||||
**为什么它是默认**:text+image 只需要一个向量空间时,同时满足「小、可商用、中文原生」
|
||||
的选项只有一个。
|
||||
|
||||
| | Chinese-CLIP | jina-v5-omni-nano | Qwen3-VL-Emb-2B |
|
||||
|---|---|---|---|
|
||||
| 参数量 | 188M | 1.04B | 2B |
|
||||
| 产物 / 实测常驻 | **721MB / 1.15GB** | ~2GB / 2.23GB | 8GB / 9.4GB |
|
||||
| 维度 | 512 | 768 | 2048 |
|
||||
| 许可 | **Apache-2.0** | CC BY-NC(不可商用) | Apache-2.0 |
|
||||
| 中文 | 原生(~2 亿中文图文对) | 多语言 | 多语言 |
|
||||
| 文本语义 | 弱(双塔对比) | 好 | 最好 |
|
||||
| 视频 | 无 | 有 | 有 |
|
||||
|
||||
**要诚实记录的代价**:CLIP 是双塔对比学习,text↔image 是强项,但**纯文本语义
|
||||
(text↔text)明显弱于 MLLM 型嵌入器**。文本检索仍由既有词向量/TF-IDF 路径兜底,
|
||||
本空间主要用于跨模态召回与相关性裁剪。需要更强文本语义或视频时切回 `qwen3vl`。
|
||||
|
||||
### 产物与获取
|
||||
|
||||
产物约 754MB,**不进仓库**;用导出脚本从官方权重导出(脚本入库,保证可复现):
|
||||
|
||||
```bash
|
||||
python3 scripts/export_chineseclip_onnx.py \
|
||||
--model-dir /path/to/chinese-clip-vit-base-patch16 \
|
||||
--out /home/newqqagent/models/chinese-clip-vit-b16-onnx
|
||||
```
|
||||
|
||||
国内下载:本机 `huggingface.co` 走代理会被 reset,用 `hf-mirror.com` 且**不设代理**:
|
||||
|
||||
```bash
|
||||
curl -4 -L --retry 3 -o vocab.txt \
|
||||
https://hf-mirror.com/OFA-Sys/chinese-clip-vit-base-patch16/resolve/main/vocab.txt
|
||||
```
|
||||
|
||||
### 产物契约(Go 侧按此读取)
|
||||
|
||||
| 文件 | 输入 | 输出 |
|
||||
|---|---|---|
|
||||
| `TextEncoder.onnx` | `input_ids` int64 `[B,52]`、`attention_mask` int64 `[B,52]` | `text_features` float `[B,512]` |
|
||||
| `VisionEncoder.onnx` | `pixel_values` float `[B,3,224,224]` | `image_features` float `[B,512]` |
|
||||
|
||||
外加 `embed_config.json`(维度/预处理/分词超参/文件名——provider 的唯一权威)、
|
||||
`vocab.txt`、`reference.json`(冻结参考:逐文本 token id + 逐样本向量)、`SHA256SUMS`。
|
||||
|
||||
图像预处理:缩放到 224×224(双三次,复刻 PIL 系数)→ `(x/255 - mean) / std`,
|
||||
不裁剪。文本:BERT WordPiece,`max_length=52`,补 `[PAD]`,超长截断尾部。
|
||||
两个塔的输出**都没有在图中归一化**,归一化由 provider 负责(检索按余弦)。
|
||||
|
||||
### 启用
|
||||
|
||||
```bash
|
||||
core.memory.multimodal_space.provider = chineseclip
|
||||
core.memory.multimodal_space.options.model_dir = /home/newqqagent/models/chinese-clip-vit-b16-onnx
|
||||
```
|
||||
|
||||
**新装默认就是这个**(`SeedDefaults` 写入 `chineseclip` + `<dataDir>/models/chinese-clip-vit-b16-onnx`),
|
||||
发行版构建也默认带 `onnxruntime` 标签(`deploy/packaging/build.sh` 的 `HOMED_TAGS`,
|
||||
需要极简构建时显式 `HOMED_TAGS=` 关闭)。
|
||||
|
||||
**老安装不会自动拿到**:播种判据是显式标记 `core.internal.seed_version`。
|
||||
老安装(已播种过)下次启动只会被补上标记,**不会**被注入新默认值——
|
||||
升级就静默加载 1.8GB 模型不是无副作用的事。要启用请显式写上面两个键。
|
||||
|
||||
> 这个判据曾经是「`config` 表为空才播种」。而发行包的 postinst 会先跑
|
||||
> `initconfig`,它写一行 `webui.listen_addr` ——于是**全新安装**被误判为
|
||||
> "已有配置",整个播种被跳过:没有 `core.plugin.dir`(装完 0 个插件)、
|
||||
> 也没有多模态 provider(随包的模型与运行库成了死重量)。回归测试
|
||||
> `TestSeedDefaultsAfterInitconfigPrepopulate` 与
|
||||
> `TestSeedDefaultsDoesNotInjectIntoLegacyInstall` 钉住了这两种情形。
|
||||
|
||||
同样要求 `homed` 带 `onnxruntime` build tag。
|
||||
|
||||
### 随包分发(server / full 包自带模型与运行库)
|
||||
|
||||
模型与运行库是发行版能力的一部分,不做成「可选下载」:
|
||||
|
||||
| 内容 | 包内路径 |
|
||||
|---|---|
|
||||
| Chinese-CLIP 产物(754MB) | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
|
||||
| ONNX Runtime(24MB) | `/usr/lib/homeagent/onnxruntime/libonnxruntime.so` |
|
||||
| 许可证 | `/usr/share/doc/homeagent/licenses/`(Apache-2.0、MIT、ThirdPartyNotices、模型来源) |
|
||||
|
||||
- `deploy/packaging/package-linux.sh` 的 `stage_multimodal_assets()` 在打 server/full 前
|
||||
会校验产物 `SHA256SUMS`、逐文件非空、运行库架构与目标一致;**缺一即失败**,
|
||||
不生成「默认启用但装完不能用」的假包。`client` 包不含(它不跑 homed)。
|
||||
- 安装时 `setup.sh` 把包内模型目录软链到 `<dataDir>/models/chinese-clip-vit-b16-onnx`
|
||||
(既不复制 754MB,也保持 dataDir 可迁移;已存在的自定义目录绝不覆盖)。
|
||||
- 服务单元设 `Environment=ONNXRUNTIME_DIR=/usr/lib/homeagent/onnxruntime`;
|
||||
provider 的查找顺序是 `ONNXRUNTIME_DIR` → `ONNX_ML_DIR` → 包内路径 →
|
||||
`/opt/onnxruntime` → `/usr/local/lib` → `/usr/lib`。
|
||||
- 构建机需自备产物:`build/model-assets/chinese-clip-vit-b16-onnx/` 与
|
||||
`build/runtime-assets/<arch>/{libonnxruntime.so,LICENSE,ThirdPartyNotices.txt}`
|
||||
(可用 `CHINESECLIP_BUNDLE_DIR` / `ONNXRUNTIME_ASSET_DIR` 覆盖)。
|
||||
|
||||
实测(从真实 deb 解包、按 postinst 顺序跑 `setup.sh`、再冷启动包内 homed):
|
||||
`multimodal space active: provider=chineseclip dim=512 fp=cd2a495cf990 modalities=[text image]`,
|
||||
并完成一次真实对话;`homeagent-server` 包 722MB(旧版 17MB),差额即模型与运行库。
|
||||
|
||||
#### ORT 环境是进程级单例(单主不析构)
|
||||
|
||||
进程内可能有多个 ORT 消费者(本 provider、`qwen3vl`、`internal/nlp` 的依存解析器)。
|
||||
`onnxruntime_go` 的行为是:第二次 `InitializeEnvironment` 报错,而
|
||||
`DestroyEnvironment` 会把别人正在用的环境一起拆掉。约定:
|
||||
|
||||
- 初始化前先 `IsInitialized()`,只有未初始化时才初始化;
|
||||
- **任何消费者都不销毁环境**(环境随进程存活),只销毁自己的会话。
|
||||
|
||||
这个缺陷是「发行版默认带 onnxruntime 标签」后才暴露的:不带标签时多个消费者不会
|
||||
同时存在(此前 `internal/nlp` 会重复初始化并降级,失败路径还会误销毁环境)。
|
||||
|
||||
### 模态范围
|
||||
|
||||
只声明 `text` 与 `image`。`audio`/`video` **明确返回 `ErrUnsupportedModality`**——
|
||||
本空间没有它们的原生编码器,用别的模型向量冒充会污染整个向量空间
|
||||
(这正是「音频明确 unsupported」那条纪律的落地)。
|
||||
|
||||
### 验证
|
||||
|
||||
Go 侧回归对着官方 PyTorch 参考(`reference.json`),模型目录由
|
||||
`CHINESECLIP_MODEL_DIR` 指定,缺失时 skip:
|
||||
|
||||
```bash
|
||||
CHINESECLIP_MODEL_DIR=/home/newqqagent/models/chinese-clip-vit-b16-onnx \
|
||||
go test -tags onnxruntime ./providers/chineseclip/ -v
|
||||
```
|
||||
|
||||
实测结果:文本 5 个用例 `cos = 1.000000000000`(与官方逐位一致);
|
||||
图像 4 个纯色用例 `cos = 1.000000`(自写 bicubic 与 PIL 在 6 位小数内一致);
|
||||
另有跨模态判别、模态拒绝、指纹稳定性、产物缺失报错等用例。
|
||||
|
||||
### 两个已踩过的坑(都在测试里钉住了)
|
||||
|
||||
1. **分词器不能自己拼**。第一版探针用 `BertTokenizer(vocab_file=..., do_lower_case=True)`
|
||||
手工分词,中文被整体切成 `[UNK]`,三个不同句子产出几乎相同的向量(余弦 0.98),
|
||||
差点把「模型坏了」当成结论。官方配置是 `do_lower_case=true` + **删音标生效** +
|
||||
**中文逐字切分**;Go 侧实现必须与官方**逐 token** 对齐(`TestTokenizerMatchesOfficialReference`)。
|
||||
2. **参考向量是未归一化的原始输出**(模长 10~36)。用「点积当余弦 + 单侧下界」判定
|
||||
会得到 13.6 而「通过」——测试里因此改成真余弦 + 双侧容差。
|
||||
|
||||
## 二、启用
|
||||
|
||||
核心不识别任何具体模型:它只按配置里的 **provider 名**从公共注册表
|
||||
(`pkg/embedding`)打开一个 provider,并把 `options.*` 原样交给它。
|
||||
模型文件布局、预处理、媒体解码、运行时都在 provider 内部。
|
||||
|
||||
```bash
|
||||
# 配置库(config.db)或 WebUI 设置页
|
||||
core.memory.multimodal_space.provider = qwen3vl
|
||||
core.memory.multimodal_space.options.model_dir = /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
|
||||
# 或换成一个外部向量服务(任何语言写的都行)
|
||||
core.memory.multimodal_space.provider = http
|
||||
core.memory.multimodal_space.options.endpoint = http://127.0.0.1:18999/embed
|
||||
core.memory.multimodal_space.options.dimension = 2048
|
||||
```
|
||||
|
||||
`options.*` 是 provider 自己的命名空间,核心不做任何解释(对 `qwen3vl` 是
|
||||
`model_dir`,对 `http` 是 `endpoint`/`dimension`/`api_key`/…)。第三方 provider
|
||||
可以定义自己的选项,无需改核心。
|
||||
|
||||
注意事项:
|
||||
|
||||
- 内置 provider `qwen3vl` 要求 `homed` 带 `onnxruntime` build tag 构建,且
|
||||
`libonnxruntime.so` 可被找到(`/opt/onnxruntime/libonnxruntime.so` 等)。
|
||||
未带 tag 时该 provider 会注册但打开时报「requires build tag」,而不是静默降级。
|
||||
- `provider` 为空时禁用多模态向量检索,退回纯 fastText 文本路径。
|
||||
- 改配置后需重启进程生效。
|
||||
- 未配置时优雅降级:文档层退到 TF-IDF 稀疏检索,媒体块仍按结构边关联,只是没有跨模态召回。
|
||||
|
||||
## 二·补、给核心接自己的模型
|
||||
|
||||
核心只依赖一个很小的公共接口(`pkg/embedding`):
|
||||
|
||||
```go
|
||||
// 输入对核心是不透明字节:modality 决定语义,Data+MIME 由 provider 解释。
|
||||
type Input struct {
|
||||
Modality Modality // text / image / audio / video / …
|
||||
Purpose Purpose // query / document
|
||||
Text string
|
||||
Data []byte
|
||||
MIME string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Embed(ctx context.Context, in Input) ([]float64, error)
|
||||
Info() Info // Dimension, Fingerprint, Modalities
|
||||
Close()
|
||||
}
|
||||
```
|
||||
|
||||
接入步骤:新建一个包,在 `init()` 里 `embedding.Register("your-model", factory)`,
|
||||
再把这个包空白导入你的发行版 `main`(或替换内置 provider 的导入行)。
|
||||
分词、预处理、解码、显存/内存管理、模型文件命名全部由你的 provider 决定。
|
||||
|
||||
两条原则值得强调:
|
||||
|
||||
- **能力是数据,不是接口方法**:支持哪些模态写在 `Info().Modalities` 里。
|
||||
这样新增模态不需要改核心接口,核心也不需要为每个新模态做类型断言。
|
||||
- **不支持的模态返回 `embedding.ErrUnsupportedModality`**,而不要拿别的模型顶替,
|
||||
也不要降级成一个普通错误——调用方靠它区分「永远不会有向量」与「本次失败可重试」。
|
||||
|
||||
## 三、模态覆盖范围
|
||||
|
||||
### Qwen3-VL-Embedding-2B(本空间,2048 维)
|
||||
|
||||
模型卡明载支持 **Text / images / screenshots / videos**;`config.json` 有
|
||||
`image_token_id` 与 `video_token_id`,**没有 `audio_token_id`/`audio_config`**。
|
||||
|
||||
| 模态 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 文本 | ✅ 原生 | `VectorizeDense` |
|
||||
| 图像 | ✅ 原生 | `EmbedImageDense`,`Vision.onnx`,固定 768×768 |
|
||||
| 视频 | ⚠️ 视觉侧已导出并校验,**Go 模板未完成** | `EmbedVideoDense` + `Vision_g{N}.onnx`;见下节 |
|
||||
| 音频 | ❌ 本轮明确不做 | 决策结果;该模型也不具备(无 `audio_token_id`) |
|
||||
|
||||
### 视频:帧 → 时间组 → M-RoPE(均已实测对齐)
|
||||
|
||||
| 项 | 值 | 验证方式 |
|
||||
|---|---|---|
|
||||
| 占位符 | `<|video_pad|>` = **151656**(图像是 `<|image_pad|>` = 151655) | 处理器实测 |
|
||||
| 模板 | 与图像同构,只换占位符 | `apply_chat_template` repr 逐字符比对 |
|
||||
| 帧→槽位 | 组 g 的 tp0←帧2g、tp1←帧2g+1 | PyTorch `torch.equal == True`,maxdiff=0;反向对照 False |
|
||||
| patch 布局 | `[G,24,24,2,2,3,2,16,16]`,即图像排列以 grid_t 为最外层堆叠 | 纯色视频于图像张量 `torch.equal == True` |
|
||||
| 视觉 token | `G×576` | 处理器实测(G=2 → 1152) |
|
||||
| M-RoPE | 每组独立:`base=start+24g`;`t=base`、`h=base+j/24`、`w=base+j%24` | 对应 `get_rope_index` 把 video grid 展开成 G 个 `t=1` 项 |
|
||||
| 用错档 | onnxruntime 报 `InvalidArgument`(维度不符) | 实验实测,**不会静默算错** |
|
||||
|
||||
同步注意事项:
|
||||
|
||||
- **帧数必须恰好是 `2×G`**(G 取已导出的档)。奇数帧时只用得上前 `2×floor(n/2)` 帧,
|
||||
多出的丢弃——不补重复帧,那会改变跳帧注意力看到的运动。
|
||||
- **`video/*`(视频文件)不能直接喂给图像入口**:Go 侧没有视频解码器,
|
||||
`EmbedImageDense(raw, "video/mp4")` 返回 `ErrModalityUnsupported`。调用方必须先抽帧。
|
||||
- 视觉图按需懒加载(每张约 1.6GB),未用到的档位不占内存。
|
||||
|
||||
### 导出视频时踩过的两个坑(都已加断言)
|
||||
|
||||
两个坑都会让产物「看起来正常、实际是错的」,且都不会在导出时报错:
|
||||
|
||||
1. **处理器会静默重采样帧**。不给 `video_metadata` 时它回落到 `fps=24`,
|
||||
把**任何**帧数都改成 `grid_t=2`:实测 4/6/8 帧全部得到 1152 个视觉 token。
|
||||
修法:`processor(..., videos=[frames], do_sample_frames=False)`。
|
||||
2. **`max_length` 只按图像算是不够的**。它是整条序列(含视觉占位符)的上限:
|
||||
图像只需 598 token,而视频是 `G×576`——G=2 要 1190、G=4 要 2342。
|
||||
沿用 1024 会截断并报
|
||||
`Mismatch in video token count between text and input_ids`。
|
||||
修法:`max_length_for(G) = max(1024, max(G)×576 + 256)`。
|
||||
|
||||
两个坑都会在导出脚本里显式断言(视觉 token 数、`video_grid_thw` 的组数),
|
||||
把错误提前到导出阶段而不是留给运行时。
|
||||
|
||||
### 音频(本轮决策:不加)
|
||||
|
||||
**Qwen3-VL 不支持音频**,由模型卡与 `config.json` 双重确认:
|
||||
|
||||
```
|
||||
模型卡:Supported Input Modalities: Text, images, screenshots, videos, and …
|
||||
config:image_token_id ✓ / video_token_id ✓ / audio_token_id ✗ / audio_config ✗
|
||||
```
|
||||
|
||||
本机有音频能力的是另一个模型(**jina-v5-omni-nano**,768 维,含
|
||||
`modeling_llava_eurobert_audio.py` 与 `audio_token_id=128256`),与 Qwen 空间
|
||||
**不同维度、不同坐标系,绝不可互相比较**。决定:**本轮不接入**;
|
||||
其侧车(`scripts/embed_sidecar.py`)也仍只实现 `text`/`image`,`audio` 返回 400。
|
||||
|
||||
无论何时接入,都**不允许**:拿视觉塔去编码音频字节、或用另一个模型的向量
|
||||
冒充某空间的音频向量——那会把两套坐标系混进同一空间,且错误是静默的。
|
||||
音频在原空间返回 `vector.ErrModalityUnsupported`,使调用方区分
|
||||
「永远不会有向量」与「本次失败可重试」。
|
||||
|
||||
Qwen3-VL 视觉塔把 `grid_thw` 当 Python 值消费(源码里是 `grid_thw.tolist()`),
|
||||
legacy tracer(`dynamo=False`)会把它固化成常量:实测把 `grid_thw` 声明为图输入后,
|
||||
导出的 ONNX 图里**根本没有该输入**,换帧数调用直接报 `Invalid input name: grid_thw`;
|
||||
导出时的 TracerWarning 明确提示
|
||||
`Converting a tensor to a Python list might cause the trace to be incorrect`。
|
||||
|
||||
因此视频的可行做法是:**在导出时固定时间组数 G,每个 G 一张 Vision 图**
|
||||
(grid = `[G, 48, 48]`),Go 侧按实际帧数选用匹配的图;用 G=2 的图去喂 G=3 的
|
||||
数据属于未定义行为。视频文件本身不能直接喂进本空间(`video/*` 返回
|
||||
`ErrModalityUnsupported`),必须由上层先抽帧。
|
||||
|
||||
## 四、验证
|
||||
|
||||
```bash
|
||||
# Go 侧:ONNX 路径(模型目录缺失时自动 skip)
|
||||
QWEN_ONNX_MODEL_DIR=/home/newqqagent/models/qwen3-vl-embed-multimodal-onnx \
|
||||
go test -tags onnxruntime ./internal/memory/qwen/ -v
|
||||
|
||||
# 排除二进制交付问题的替代:先单独验证模型与 CSV 无关的 ONNX 图
|
||||
go vet -tags onnxruntime ./...
|
||||
```
|
||||
|
||||
Go 测试覆盖:冻结参考向量(文本/图像各 12 维)、同输入确定性、不同输入敏感性、
|
||||
图像与文本向量必须不同、以及音频/视频必须返回 `ErrModalityUnsupported`。
|
||||
|
||||
冻结参考向量由导出脚本写入**产物目录本身**(`<out>/qwen_reference.json`),
|
||||
来源可追溯:同一脚本既产出模型,也产出「这个模型对固定输入应有的输出」。
|
||||
重新导出后若参考值变化,说明权重或图结构变了,必须显式更新参考而不是放宽阈值。
|
||||
|
||||
> **参考向量是 L2 归一化后的值。** ONNX 图返回的是 final norm 之后的原始
|
||||
> last hidden(量级约 100),而 Go 侧 `VectorizeDense` / `EmbedImageDense`
|
||||
> 返回归一化向量。写参考时忘归一化,Go 测试会全线不匹配,而现象看起来
|
||||
> 像“模型不对”,实际只是两边对“向量”的定义不同。
|
||||
|
||||
验证既有产物(不重新导出):
|
||||
|
||||
```bash
|
||||
python3 scripts/export_qwen3vl_embedding_onnx.py --verify-only --model-dir <model> \
|
||||
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
|
||||
```
|
||||
|
||||
脚本会顺便把归一化后的参考向量写入该目录。
|
||||
|
||||
### 与现有部署产物的等价性
|
||||
|
||||
本仓库脚本对同一源模型导出时,`TokenEmbedding.onnx` 与 `Transformer.onnx` 与
|
||||
线上在用的产物**逐字节相同**(sha256 一致);`Vision.onnx` 差异仅在打包形式:
|
||||
旧产物把权重量到外部 `Vision.onnx.data`,新脚本内联在图里。两者数值等价。
|
||||
|
||||
注意这会带来一个**操作性**差异:Go 的结构指纹(`computeFingerprint`)把
|
||||
`*.onnx.data` 的文件名与大小算在内,因此「外部权重版 ↔ 内联版」互换会让
|
||||
fingerprint 变化,从而触发一次全量向量重算。重算不会**算错**(数值等价),
|
||||
只是白花一次 CPU;若不想触发,就保持产物打包形式不变。
|
||||
|
||||
## 五、资源成本
|
||||
|
||||
- 产物磁盘约 8 GB;导出过程峰值内存约 10–12 GB(FP32 加载)。
|
||||
- 单次 CPU 推理:文本约几十毫秒量级,图像(2304 patch 过 24 层视觉塔 + 28 层语言模型)
|
||||
明显更重,因此入库时不阻塞对话,靠 `reembedStaleMedia` 在启动时并发迁移
|
||||
(ONNX 路径 4 worker)。
|
||||
- fingerprint 由三段图 + `embed_config.json` + 外部权重文件名/大小共同决定;
|
||||
换模型或重新导出都会让它变化,从而触发历史向量重算——这是预期行为。
|
||||
|
||||
## 视频:当前状态(未完成,不得当作已验证)
|
||||
|
||||
**视觉侧**:`Vision_g2/g3/g4.onnx` 已导出,且每一档都与完整 PyTorch 模型逐档对过
|
||||
(`cos` 分别为 1.000000119 / 1.000000119 / 1.000000000,覆盖度断言通过)。
|
||||
|
||||
**Go 侧模板**:与 HuggingFace processor 产出**不相等**,因此冻结回归
|
||||
(`TestEmbedderVideoMatchesONNXReference`)当前**显式跳过**并注明原因,不算通过。
|
||||
|
||||
已定位的差异:processor 会按时间组插入字面时间戳文本。逐 token 实测:
|
||||
|
||||
```
|
||||
<|vision_start|> <0.0 seconds> <|vision_start|> {576×<|video_pad|>} <|vision_end|>
|
||||
<1.0 seconds> <|vision_start|> {576×<|video_pad|>} <|vision_end|>
|
||||
```
|
||||
|
||||
而 Go 侧只生成 `<|vision_start|>{G×576 pads}<|vision_end|>`。同一输入下
|
||||
Python `seq=1190`(1152 视觉 + **38** 文本),Go 侧只有 **22** 个文本 token。
|
||||
|
||||
注意两点:
|
||||
|
||||
- 时间戳文本**也占用 M-RoPE 位置**,所以 `TestVideoModelInputMRope` 的自洽断言
|
||||
通过**不能**证明与官方实现一致(它是拿自己算的序列验自己算的位置)。
|
||||
- 修复位置在 provider 内部(模型专属模板本就属于 provider),不是核心。
|
||||
|
||||
另外,公共 provider 契约把 `Data+MIME` 交给 provider 自行解码;本 provider
|
||||
没有视频解码器(Go 标准库不含 H.264/MP4),因此 `Info().Modalities` **不声明 video**,
|
||||
`Embed(video)` 返回 `ErrUnsupportedModality`。视频走 provider 自己的
|
||||
`EmbedVideoDense`(接收已解码帧)。待核心有了对 provider 不透明的多帧容器后,
|
||||
再把视频纳入公共契约。
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:**完成 v3**(2026-09-06)——v2 的迁移已上生产(内核 v1.0.0);v3 记录 v1.1.1 的公开接口**扩展**。
|
||||
> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致;
|
||||
> 所有改造落在**核心(homed 侧)+ 工具链(plugindev)**,外部插件业务代码零改动,只需用新 plugindev 重编。
|
||||
> 所有改造落在**核心(homed 侧)+ 工具链(hmapdev,当时名为 plugindev)**,外部插件业务代码零改动,只需用新工具链重编。
|
||||
>
|
||||
> **结果(已验证)**:`git diff third_party/homeagent-sdk/sdk/` 全程为空;17 个 `example/*/plugin.go` 逐字节未改
|
||||
> (`git status example/` 无输出);生产 17 插件全部经子进程通道运行。
|
||||
@ -11,7 +11,7 @@
|
||||
> 它要保的是「换运行模型不动业务代码」。迁移完成后,SDK 需要能随功能演进而扩展,
|
||||
> 否则多模态这类能力永远到不了插件手上。解除的边界见 §九:**只增不减,签名不改**。
|
||||
>
|
||||
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/plugindev/templates/` 后,
|
||||
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/hmapdev/templates/` 后,
|
||||
> 必须同步更新本矩阵。
|
||||
>
|
||||
> 权威编号:plan.md 第 11 节(11.1~11.9)。本文档只做接口面盘点,不做实现。
|
||||
@ -21,9 +21,9 @@
|
||||
## 一、迁移的形状(一句话)
|
||||
|
||||
```
|
||||
今天: 外部插件 = example/*/plugin.go(纯 Go) ──plugindev c-shared──> plugin.so
|
||||
今天: 外部插件 = example/*/plugin.go(纯 Go) ──hmapdev c-shared──> plugin.so
|
||||
homed ──dlopen──> plugin.so(C ABI bridge:51 个整数 method id)
|
||||
之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──plugindev go build──> plugin.bin
|
||||
之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──hmapdev go build──> plugin.bin
|
||||
homed ──spawn──> plugin.bin(stdio JSON-RPC + shm + eventfd)
|
||||
```
|
||||
|
||||
@ -33,8 +33,8 @@
|
||||
|---|---|---|
|
||||
| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) |
|
||||
| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go(只 import 公开 SDK) | **不动**(只重编) |
|
||||
| bridge 模板 `tools/plugindev/templates.go` 的 `tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
|
||||
| `plugindev` 构建命令 | c-shared | 改普通 `go build` |
|
||||
| bridge 模板 `tools/hmapdev/templates.go` 的 `tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
|
||||
| `hmapdev` 构建命令 | c-shared | 改普通 `go build` |
|
||||
| homed `internal/plugin/cabi/`(1096 行) | cgo | 删(已归入 plan 迁移收尾 5.2) |
|
||||
| homed `internal/plugin/registry.go` 加载分派 | — | 改:按 `entry` 分派 `.so`/`.bin` |
|
||||
|
||||
@ -300,7 +300,7 @@ C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 c
|
||||
|
||||
## 七、接口冻结检查点(全部已通过)
|
||||
|
||||
1. ✅ **阶段 2(子进程通道原型)**:`plugindev` 重编 weather → `plugin.bin` → 端到端跑通。
|
||||
1. ✅ **阶段 2(子进程通道原型)**:`hmapdev` 重编 weather → `plugin.bin` → 端到端跑通。
|
||||
验收:weather 业务代码逐字节未改(`git status example/` 无输出)。
|
||||
2. ✅ **阶段 3(共享内存)**:子进程并发改写 StageContext 丢失率 = 0%
|
||||
(`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate` 与
|
||||
@ -349,7 +349,7 @@ tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
|
||||
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
|
||||
|
||||
公开接口加方法时,`tools/plugindev/templates/proc_main.go.tmpl` 里的 `procIO` /
|
||||
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的 `procIO` /
|
||||
`procDocMemory` 若不实现新方法,就不满足接口——**每个外部插件都编不过**,是硬失败
|
||||
不是软降级。v1.1.1 这一层是被 `go test` 抓出来的(`internal/plugin/proc` 的两个
|
||||
E2E 用例编译失败),不是靠人工检查发现的。
|
||||
@ -366,9 +366,48 @@ E2E 用例编译失败),不是靠人工检查发现的。
|
||||
|---|---|---|
|
||||
| 存量插件源码零改动 | `cd example/<n> && go vet ./...`(17 个) | ✅ 17/17 通过 |
|
||||
| 旧产物仍能建链 | 用 SDK 0.9.2 编的 `plugin.bin` 跑 `TestRealPlugin_*` | ✅ 4/4 通过(握手校验 `ProtocolVersion=1`,不是 SDK 版本) |
|
||||
| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
|
||||
| 模板已接线 | `cd tools/hmapdev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
|
||||
| 并发安全 | `go test ./sdk/ -race -count=5` | ✅ 零 DATA RACE(13 例压测) |
|
||||
|
||||
### v1.2.x 的接口扩展(2026-09-12)
|
||||
|
||||
1.2.0 把「记不记入记忆 / 要不要据此裁剪上下文」从**只有工具与通道能声明**,扩到**注入侧也能声明**:
|
||||
|
||||
| 新增 | 方向 | 说明 |
|
||||
|---|---|---|
|
||||
| `InjectOptions{NoMemory, ContextPolicy, CleanerName}` | 新增类型 | 单次注入的行为声明 |
|
||||
| `ContextPolicyNone` / `ContextPolicyPrune` + `ValidContextPolicy` | 新增常量/函数 | 取值只有 `""` / `none` / `prune`;`prune` 必须显式声明 |
|
||||
| 六个 `*Opts` 变体(Text / InterruptText / InputSync / InputMedia / InputMediaSync / InterruptMedia) | 插件调用、内核实现 | 旧的三参数方法保留为**零值糖**,与 `InjectOptions{}` 逐键等价 |
|
||||
| `ChannelDef.ContextPolicy` + `ChannelDef` 的 JSON tag | 结构体字段 | 通道也可声明裁剪;补 tag 是因为通道定义要跨进程传给内核,而 `Cleaner` 是函数必须忽略——无 tag 时新增字段会被**静默丢掉** |
|
||||
|
||||
签名层面零变更(六个方法全是新增),满足第 1、2 条。
|
||||
|
||||
**但「接口纯追加」不等于「无需重编」**:1.2.0 同时把插件运行协议升到 2
|
||||
(fd3 布局改变,不支持滚动升级),`ProtocolVersion` 不匹配会在握手时被明确拒绝
|
||||
并提示用配套 plugindev 重编。两件事必须分开说,否则会被误读成「既然纯追加就还能用旧产物」。
|
||||
|
||||
#### 这次扩展自己抓出来的两处漂移(都是本节第 3 条要防的那类)
|
||||
|
||||
1. **模板接线守卫红了**:`TestProcTemplate_CoversAllCoreMethods` 要求模板出现内核提供的
|
||||
每一个 method id,而注入标志位落地后模板不再发 `io.injectTextNoMem`(旧模板发它,
|
||||
现在走 `io.injectText` + `NoMemory` 标志位)。内核保留该 id 是**刻意的向后兼容面**
|
||||
(用那时模板编出的二进制仍在外面),不是漏接线——所以改的是判据:把它移入显式的
|
||||
`deprecated` 表,并加**反向保护**(条目一旦重新出现在模板里就报错,避免这张表
|
||||
退化成「永久豁免」的垃圾抽屉)。
|
||||
2. **mocksdk 缺一个方法**:拿公共 SDK `IOInjector` 的 14 个方法名与 mock 的方法集
|
||||
**机械求差**,差集恰好是旧的三参数 `InjectInputSync`——通道类插件(qq / a2a)完成
|
||||
「入站 → agent 处理 → 回复取回」闭环要调的那个。`git log -S` 证实它**从来就缺**,
|
||||
不是本次引入;补齐后差集为空。(上次漂的是 `Triple.Predicate` vs `Relation`,同一类问题。)
|
||||
|
||||
#### 验证(1.2.0,本机实测)
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 存量插件源码零改动 | 逐个 `cd example/<n> && go vet ./...` | ✅ 17/17 通过(`luademo` 是 Lua、无 `go.mod`,跳过) |
|
||||
| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ 全绿(修复前为红;反向保护另用「把 id 塞回模板」验证过会报错) |
|
||||
| 并发安全 | `go test -race -count=5 ./sdk/` | ✅ ok |
|
||||
| mocksdk 未漂移 | 方法集求差(14 个方法) | ✅ 差集为空 |
|
||||
|
||||
### 为何媒体块走 JSON 而不是共享段二进制通道
|
||||
|
||||
`SetToolBlocks` 的原设计是「二进制落 arena,Slice 描述符回传」。实际落地时改走 JSON:
|
||||
@ -387,5 +426,5 @@ data URL 本身已是 base64 文本,包进二进制传输省不了空间,还
|
||||
- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板)
|
||||
- `internal/plugin/proc/shm.go` — 合同面 C 的代码实现(共享段布局与 18 字段枚举)
|
||||
- `internal/plugin/proc/capability.go` — 权限梯度(capability 组 + `withheldCapabilities`)
|
||||
- `third_party/homeagent-sdk/tools/plugindev/templates/` — 子进程运行时模板(三文件)
|
||||
- `third_party/homeagent-sdk/tools/hmapdev/templates/` — 子进程运行时模板(三文件)
|
||||
- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具
|
||||
3
go.mod
3
go.mod
@ -18,6 +18,7 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/text v0.3.8
|
||||
)
|
||||
|
||||
require (
|
||||
@ -40,8 +41,6 @@ require (
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ./third_party/homeagent-sdk
|
||||
|
||||
@ -17,6 +17,7 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
@ -51,20 +52,18 @@ type Agent struct {
|
||||
// 文本记忆(原始对话日志)
|
||||
textMem *text.Memory
|
||||
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
||||
// L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过——
|
||||
// 它是记忆增强而非对话必需品,缺了不该让对话失败。
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重。
|
||||
// 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
|
||||
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
|
||||
mediaStore *media.Store
|
||||
// mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。
|
||||
mediaGCInterval time.Duration
|
||||
// mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。
|
||||
mediaGCMinAge time.Duration
|
||||
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
|
||||
mediaDescribe bool
|
||||
|
||||
// 人格设定
|
||||
// 人格设定(内容来自启动时载入的人格文件/配置项)
|
||||
personality *agentPkg.Personality
|
||||
|
||||
// 人格落库面:首启门禁与 persona_set 工具使用(见 persona.go)。
|
||||
// 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。
|
||||
personaStore PersonaStore
|
||||
|
||||
// 插件注册表(用于 plgreload)
|
||||
pluginReg *plugin.Registry
|
||||
pluginDir string
|
||||
@ -95,10 +94,17 @@ type Agent struct {
|
||||
selfInputCh chan selfInputMsg
|
||||
|
||||
// 子任务异步执行
|
||||
childMu sync.Mutex
|
||||
childNextID int64
|
||||
childResults map[string]string
|
||||
childRunning map[string]bool // 运行中的子任务(child_result 查询时区分'运行中'与'不存在')
|
||||
childMu sync.Mutex
|
||||
childNextID int64
|
||||
// childTasks 记录子任务状态:运行中 / 结果 / 是否已交付。
|
||||
//
|
||||
// 为什么保留结果而不是“读到即删”:完成通知会写进持久上下文
|
||||
// (formatMergedTimeline 每轮都重新注入),模型之后还会再查。若读到即删,
|
||||
// 第二次查询就得到“不存在或已过期”这个**永久失败信号**——模型据此认为
|
||||
// 任务未完成,会无限重试/汇报(实测单轮 35 次工具调用、持续 514 秒)。
|
||||
childTasks map[string]*childTaskState
|
||||
// childSeq 给完成的任务排个序,用于有界淘汰。
|
||||
childSeq int64
|
||||
|
||||
// 高优先级打断通道:interceptLoop 注入,process() 在工具循环轮次间非阻塞读取
|
||||
interceptCh chan *agentIO.InputEvent
|
||||
@ -144,6 +150,19 @@ type Agent struct {
|
||||
// 词嵌入模型,用于实体语义相似度计算
|
||||
embedder *memory.StaticEmbedder
|
||||
|
||||
// multimodalSpace 是统一多模态向量空间(可选)。实现可以是内嵌 ONNX,
|
||||
// 也可以是外部 API 客户端;两者共享同一套 L0/L2/L3 向量缓存与检索基础设施。
|
||||
multimodalSpace vector.MultimodalEmbedder
|
||||
|
||||
// embeddingProvider 是配置里指定的统一向量空间 provider 名;
|
||||
// embeddingError 是打开/适配失败的原因(成功时为空)。
|
||||
// 二者只用于状态报告:区分「没配」「配了但打不开」「已启用」。
|
||||
embeddingProvider string
|
||||
embeddingError string
|
||||
|
||||
// fusionCfg 控制文本路与视觉路的跨模态融合权重,可按模型实测结果配置。
|
||||
fusionCfg CrossModalFusionConfig
|
||||
|
||||
// 技能索引提供者:由 skillmgr 插件实现,向 system prompt 注入轻量技能索引
|
||||
skillIndex SkillIndexProvider
|
||||
}
|
||||
@ -166,15 +185,19 @@ type AgentConfig struct {
|
||||
Indexer *memory.Indexer
|
||||
Tracker *tracker.Tracker
|
||||
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
MediaGCInterval time.Duration
|
||||
MediaGCMinAge time.Duration
|
||||
MediaDescribe bool
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
|
||||
// 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。
|
||||
EmbeddingProvider string
|
||||
EmbeddingError string
|
||||
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
||||
Personality *agentPkg.Personality
|
||||
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
|
||||
PluginReg *plugin.Registry
|
||||
PluginDir string
|
||||
DistillInterval time.Duration
|
||||
@ -217,8 +240,7 @@ func New(cfg AgentConfig) *Agent {
|
||||
embedder = memory.NewStaticEmbedder(strings.Split(cfg.EmbeddingModelPath, ",")...)
|
||||
}
|
||||
if cfg.DocStore != nil {
|
||||
cfg.DocStore.SetVectorizer(embedder)
|
||||
cfg.DocStore.ReindexWithVectorizer(embedder)
|
||||
// TF-IDF 内置为 fallback,无需外部注入
|
||||
}
|
||||
if cfg.Knowledge != nil {
|
||||
cfg.Knowledge.SetVectorizer(embedder)
|
||||
@ -232,55 +254,60 @@ func New(cfg AgentConfig) *Agent {
|
||||
if cfg.IO != nil {
|
||||
rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef)
|
||||
}
|
||||
// 必须把媒体存储也注给 RelevanceContext:L0→L2 归档(Prune)靠
|
||||
// rc.transferMediaRefs 把引用从 context owner 转给 document owner。
|
||||
// 漏了这一行的后果是静默的:rc.mediaStore 为 nil 时转移直接 return,
|
||||
// 而携带引用的 ContextEvent 已被归档删除 → 引用永久悬空在
|
||||
// context owner 上、计数永不归零 → 对应 blob 永远不会被 GC 回收。
|
||||
rc.SetMediaStore(cfg.MediaStore)
|
||||
// 注入稠密多模态向量空间(可选):配置后文档检索、L0 相关性裁剪、
|
||||
// 跨模态检索全部共享同一向量空间,取代稀疏 fastText 语义路。
|
||||
// 未配置时退化到 TF-IDF/fastText 稀疏检索,保持既有行为。
|
||||
if cfg.MultimodalSpace != nil && cfg.MultimodalSpace.Loaded() {
|
||||
rc.SetDenseSpace(cfg.MultimodalSpace)
|
||||
if cfg.DocStore != nil {
|
||||
cfg.DocStore.SetDenseSpace(cfg.MultimodalSpace)
|
||||
cfg.DocStore.BuildDenseIndex(cfg.MultimodalSpace)
|
||||
}
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
mediaGCInterval: cfg.MediaGCInterval,
|
||||
mediaGCMinAge: cfg.MediaGCMinAge,
|
||||
mediaDescribe: cfg.MediaDescribe,
|
||||
personality: cfg.Personality,
|
||||
pluginReg: cfg.PluginReg,
|
||||
pluginDir: cfg.PluginDir,
|
||||
distillInterval: cfg.DistillInterval,
|
||||
archiveInterval: cfg.ArchiveInterval,
|
||||
reviewInterval: cfg.ReviewInterval,
|
||||
mergeInterval: cfg.MergeInterval,
|
||||
maxContextSize: cfg.MaxContextSize,
|
||||
stageHost: cfg.StageHost,
|
||||
skillIndex: cfg.SkillIndexProvider,
|
||||
eventBus: cfg.EventBus,
|
||||
selfInputCh: make(chan selfInputMsg, 64),
|
||||
childResults: make(map[string]string),
|
||||
childRunning: make(map[string]bool),
|
||||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
||||
pluginHealth: newPluginHealthTracker(),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
embedder: embedder,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
personality: cfg.Personality,
|
||||
personaStore: cfg.PersonaStore,
|
||||
pluginReg: cfg.PluginReg,
|
||||
pluginDir: cfg.PluginDir,
|
||||
distillInterval: cfg.DistillInterval,
|
||||
archiveInterval: cfg.ArchiveInterval,
|
||||
reviewInterval: cfg.ReviewInterval,
|
||||
mergeInterval: cfg.MergeInterval,
|
||||
maxContextSize: cfg.MaxContextSize,
|
||||
stageHost: cfg.StageHost,
|
||||
skillIndex: cfg.SkillIndexProvider,
|
||||
eventBus: cfg.EventBus,
|
||||
selfInputCh: make(chan selfInputMsg, 64),
|
||||
childTasks: make(map[string]*childTaskState),
|
||||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
||||
pluginHealth: newPluginHealthTracker(),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
embedder: embedder,
|
||||
multimodalSpace: cfg.MultimodalSpace,
|
||||
embeddingProvider: cfg.EmbeddingProvider,
|
||||
embeddingError: cfg.EmbeddingError,
|
||||
fusionCfg: cfg.FusionCfg,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
@ -294,8 +321,8 @@ func (a *Agent) Start() {
|
||||
go a.archiveLoop()
|
||||
go a.mergeLoop()
|
||||
go a.reviewLoop()
|
||||
go a.mediaGCLoop()
|
||||
go a.mediaDescribeLoop()
|
||||
a.reembedStaleMedia()
|
||||
a.migrateLegacyGraphMedia()
|
||||
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
||||
}
|
||||
|
||||
|
||||
@ -15,14 +15,14 @@ type mockOutputDevice struct {
|
||||
toolFn func(string, map[string]interface{}) (interface{}, error)
|
||||
}
|
||||
|
||||
func (d *mockOutputDevice) Name() string { return d.name }
|
||||
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
|
||||
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
|
||||
func (d *mockOutputDevice) Start() error { return nil }
|
||||
func (d *mockOutputDevice) Stop() error { return nil }
|
||||
func (d *mockOutputDevice) Name() string { return d.name }
|
||||
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
|
||||
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
|
||||
func (d *mockOutputDevice) Start() error { return nil }
|
||||
func (d *mockOutputDevice) Stop() error { return nil }
|
||||
func (d *mockOutputDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
|
||||
func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||
func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||
func (d *mockOutputDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
if d.toolFn != nil {
|
||||
return d.toolFn(tool, args)
|
||||
@ -67,8 +67,8 @@ func TestExecuteOutputSendTool(t *testing.T) {
|
||||
"type": "text",
|
||||
}}
|
||||
result := a.executeOutputSendTool(tc)
|
||||
if !strings.Contains(result, "screen") {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
if result != "ok" {
|
||||
t.Errorf("expected ok, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ package core
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@ -13,7 +12,6 @@ import (
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
@ -24,9 +22,8 @@ type ToolResultItem struct {
|
||||
}
|
||||
|
||||
type ContextEvent struct {
|
||||
// ID 是事件的稳定标识,媒体引用(media_refs.owner_id)挂在它上面。
|
||||
// ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。
|
||||
//
|
||||
// 惰性生成:只有真的要挂媒体时才赋值(见 bindEventMedia)。
|
||||
// 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。
|
||||
// omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。
|
||||
ID string `json:"id,omitempty"`
|
||||
@ -36,13 +33,12 @@ type ContextEvent struct {
|
||||
Response string `json:"response,omitempty"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
// Media 是本轮对话涉及的媒体 digest(sha256 十六进制)。
|
||||
//
|
||||
// 存 digest 而不存路径:路径会失效(/tmp 探针图、下载缓存、别的进程的
|
||||
// 临时产物),digest 是内容本身的身份,配合 internal/memory/media 的 CAS
|
||||
// 永远能取回原始字节——只要它还没被容量 GC 淘汰。
|
||||
Media []string `json:"media,omitempty"`
|
||||
Vector vector.Vector `json:"-"`
|
||||
// --- 原生多模态记忆 ---
|
||||
// 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。
|
||||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
||||
Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间)
|
||||
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
|
||||
DenseFP string `json:"-"` // DenseVec 所属统一空间指纹(缓存字段,不持久化)
|
||||
}
|
||||
|
||||
const contextFlushInterval = 5 * time.Second
|
||||
@ -51,46 +47,12 @@ type RelevanceContext struct {
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
embedder *memory.StaticEmbedder
|
||||
denseSpace vector.MultimodalEmbedder
|
||||
savePath string
|
||||
saveTimer *time.Timer
|
||||
dirty bool
|
||||
toolDefLookup func(name string) *sdk.ToolDef
|
||||
channelDefLookup func(name string) (sdk.ChannelDef, bool)
|
||||
|
||||
// mediaStore 只用于 Prune 时把媒体引用从事件转给归档文档。
|
||||
// 为 nil 时引用转移静默跳过(媒体存储未启用)。
|
||||
mediaStore *media.Store
|
||||
}
|
||||
|
||||
// SetMediaStore 注入媒体存储,供 L0→L2 归档时转移媒体引用。
|
||||
func (c *RelevanceContext) SetMediaStore(s *media.Store) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.mediaStore = s
|
||||
}
|
||||
|
||||
// transferMediaRefs 把被归档事件的媒体引用转给目标文档(调用方已持 c.mu)。
|
||||
//
|
||||
// 先挂后销:若反序,引用计数会瞬时归零,此时若后台 GC 正在跑
|
||||
// 就会把仍被记忆引用的内容当孤儿清掉。
|
||||
func (c *RelevanceContext) transferMediaRefs(archive []scoredEvent, docID string) {
|
||||
if c.mediaStore == nil || docID == "" {
|
||||
return
|
||||
}
|
||||
for _, s := range archive {
|
||||
evt := s.event
|
||||
if evt == nil || evt.ID == "" || len(evt.Media) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, d := range evt.Media {
|
||||
if err := c.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
|
||||
log.Printf("[media] 归档转移 AddRef 失败 (%s → doc %s): %v", shortDigest(d), docID, err)
|
||||
}
|
||||
}
|
||||
if _, err := c.mediaStore.DropOwner(media.OwnerContext, evt.ID); err != nil {
|
||||
log.Printf("[media] 归档转移 DropOwner 失败 (evt %s): %v", evt.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
|
||||
@ -104,6 +66,14 @@ func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *Rele
|
||||
return rc
|
||||
}
|
||||
|
||||
// SetDenseSpace 注入稠密多模态向量空间。配置后 L0 相关性裁剪可用稠密向量
|
||||
// 余弦(与媒体检索、文档检索共享同一空间),未配置时退化到稀疏词向量。
|
||||
func (c *RelevanceContext) SetDenseSpace(ds vector.MultimodalEmbedder) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.denseSpace = ds
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@ -126,7 +96,7 @@ func (c *RelevanceContext) load() {
|
||||
return
|
||||
}
|
||||
for _, evt := range events {
|
||||
evt.Vector = c.computeVector(evt)
|
||||
c.computeVector(evt)
|
||||
}
|
||||
c.events = events
|
||||
}
|
||||
@ -225,12 +195,32 @@ func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector {
|
||||
func (c *RelevanceContext) computeVector(evt *ContextEvent) {
|
||||
text := textForVector(evt, c.toolDefLookup, c.channelDefLookup)
|
||||
if text == "" {
|
||||
return nil
|
||||
// 稀疏向量始终计算(TF-IDF/fastText,退化时仍可用)
|
||||
if text != "" {
|
||||
evt.Vector = c.embedder.Vectorize(text)
|
||||
}
|
||||
// 稠密向量:文本向量 ⊕ 本事件持有的一等记忆块媒体向量(同一统一空间)。
|
||||
// 只有媒体的输入(无文本)也要有可比较的坐标,因此不再按 text=="" 提前返回。
|
||||
if c.denseSpace != nil && c.denseSpace.Loaded() {
|
||||
fp := c.denseSpace.Fingerprint()
|
||||
var parts [][]float64
|
||||
if text != "" {
|
||||
if dv, err := c.denseSpace.VectorizeDense(text); err == nil && len(dv) > 0 {
|
||||
parts = append(parts, dv)
|
||||
}
|
||||
}
|
||||
for _, b := range evt.Blocks {
|
||||
// 只融合同指纹的块向量:另一套坐标系的向量混进来会算出
|
||||
// 两边都不像的方向。
|
||||
if len(b.Vector) > 0 && b.Fingerprint == fp {
|
||||
parts = append(parts, b.Vector)
|
||||
}
|
||||
}
|
||||
evt.DenseVec = vector.FuseVectors(parts...)
|
||||
evt.DenseFP = fp
|
||||
}
|
||||
return c.embedder.Vectorize(text)
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) Save() error {
|
||||
@ -251,7 +241,7 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
evt.Vector = c.computeVector(&evt)
|
||||
c.computeVector(&evt)
|
||||
c.events = append(c.events, &evt)
|
||||
|
||||
c.save()
|
||||
@ -261,7 +251,7 @@ func (c *RelevanceContext) InsertByTimestamp(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
evt.Vector = c.computeVector(&evt)
|
||||
c.computeVector(&evt)
|
||||
|
||||
idx := sort.Search(len(c.events), func(i int) bool {
|
||||
return c.events[i].Timestamp.After(evt.Timestamp)
|
||||
@ -307,8 +297,7 @@ func (c *RelevanceContext) flush() {
|
||||
|
||||
// scoredEvent 是 Prune 里按相关度排序的事件。
|
||||
//
|
||||
// 提为包级类型(原先是 Prune 内的局部类型):transferMediaRefs 需要
|
||||
// 把待归档列表传进去,局部类型无法出现在方法签名上。
|
||||
// 提为包级类型:Prune 需要把待归档列表传给后续处理。
|
||||
type scoredEvent struct {
|
||||
event *ContextEvent
|
||||
score float64
|
||||
@ -334,11 +323,29 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
return 0
|
||||
}
|
||||
|
||||
// 优先使用稠密向量余弦(与媒体/文档共享空间);退化到稀疏词向量。
|
||||
var queryDense []float64
|
||||
useDense := false
|
||||
queryFP := ""
|
||||
if c.denseSpace != nil && c.denseSpace.Loaded() {
|
||||
if dv, err := c.denseSpace.VectorizeDense(currentInput); err == nil {
|
||||
queryDense = dv
|
||||
queryFP = c.denseSpace.Fingerprint()
|
||||
useDense = true
|
||||
}
|
||||
}
|
||||
queryVec := c.embedder.VectorizeClean(currentInput)
|
||||
|
||||
scoredEvents := make([]scoredEvent, len(candidates))
|
||||
for i, evt := range candidates {
|
||||
score := vector.CosineSimilarity(queryVec, evt.Vector)
|
||||
var score float64
|
||||
// 只在同一统一空间内比稠密余弦:换了模型/维度后旧事件的向量
|
||||
// 属于另一个坐标系,拿来比会得到无意义的分数。
|
||||
if useDense && evt.DenseFP == queryFP && len(evt.DenseVec) == len(queryDense) {
|
||||
score = vector.DenseCosine(queryDense, evt.DenseVec)
|
||||
} else {
|
||||
score = vector.CosineSimilarity(queryVec, evt.Vector)
|
||||
}
|
||||
scoredEvents[i] = scoredEvent{event: evt, score: score, idx: i}
|
||||
}
|
||||
|
||||
@ -376,16 +383,20 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
Content: s.event.Input,
|
||||
Response: s.event.Response,
|
||||
ToolResults: convertToolResults(s.event.ToolResults),
|
||||
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
|
||||
}
|
||||
}
|
||||
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
|
||||
if err == nil && doc != nil {
|
||||
archived = len(entries)
|
||||
// 媒体引用随事件一起从 L0 转到 L2:先把引用挂到归档文档上,
|
||||
// 再注销原事件的引用。顺序不能反——先销后挂会让引用计数
|
||||
// 瞬时归零,若此时 GC 正在跑(后台任务)就会把仍被记忆引用的
|
||||
// 内容当孤儿清掉。
|
||||
c.transferMediaRefs(archive, doc.ID)
|
||||
// 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。
|
||||
// 迁移的是块本身(ID 不变、只换持有层),不是复制也不是保活引用;
|
||||
// 因此归档后清空源事件的块,确保同一块不同时留在两层。
|
||||
for _, s := range archive {
|
||||
if s.event != nil {
|
||||
s.event.Blocks = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -428,6 +439,18 @@ func (c *RelevanceContext) Recent(n int) []ContextEvent {
|
||||
return result
|
||||
}
|
||||
|
||||
// Blocks 返回当前上下文持有的一等记忆块(供跨层存活判定)。
|
||||
// 迁移后源事件已被清空,因此这里只会拿到真正属于 L0 的块。
|
||||
func (c *RelevanceContext) Blocks() []memory.MemoryBlock {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var out []memory.MemoryBlock
|
||||
for _, e := range c.events {
|
||||
out = append(out, e.Blocks...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) Len() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
268
internal/agent/core/crossmodal.go
Normal file
268
internal/agent/core/crossmodal.go
Normal file
@ -0,0 +1,268 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// CrossModalHit 是跨模态检索融合后的一条候选。
|
||||
//
|
||||
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都由层容器持有,
|
||||
// 只有仍被某层记忆块持有的媒体才可召回。Doc 是 L2 文档;Media 是该块携带的
|
||||
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
|
||||
type CrossModalHit struct {
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *media.Item // 视觉路命中的媒体;文本路命中时也可能带关联媒体
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
// 该媒体同时被两路命中(文本路经文档关联、视觉路直接命中)时,
|
||||
// DoubleHit=true —— 双信号确认,应排在只被一路命中的候选之前。
|
||||
DoubleHit bool
|
||||
}
|
||||
|
||||
// CrossModalFusionConfig 控制文本路与视觉路的融合行为。
|
||||
// 默认各路权重 0.5,双命中加权 0.15;不同模型/场景可按实测调整。
|
||||
type CrossModalFusionConfig struct {
|
||||
WeightText float64 // 文本路融合权重(默认 0.5)
|
||||
WeightVisual float64 // 视觉路融合权重(默认 0.5)
|
||||
DoubleHitBonus float64 // 双命中额外加分(默认 0.15)
|
||||
MinMaxEps float64 // min-max 归一化除零保护(默认 1e-12)
|
||||
}
|
||||
|
||||
var defaultFusionConfig = CrossModalFusionConfig{
|
||||
WeightText: 0.5,
|
||||
WeightVisual: 0.5,
|
||||
DoubleHitBonus: 0.15,
|
||||
MinMaxEps: 1e-12,
|
||||
}
|
||||
|
||||
func (c CrossModalFusionConfig) textWeight() float64 {
|
||||
if c.WeightText <= 0 {
|
||||
return defaultFusionConfig.WeightText
|
||||
}
|
||||
return c.WeightText
|
||||
}
|
||||
func (c CrossModalFusionConfig) visualWeight() float64 {
|
||||
if c.WeightVisual <= 0 {
|
||||
return defaultFusionConfig.WeightVisual
|
||||
}
|
||||
return c.WeightVisual
|
||||
}
|
||||
func (c CrossModalFusionConfig) doubleHitBonus() float64 {
|
||||
return c.DoubleHitBonus
|
||||
}
|
||||
func (c CrossModalFusionConfig) minMaxEps() float64 {
|
||||
if c.MinMaxEps <= 0 {
|
||||
return defaultFusionConfig.MinMaxEps
|
||||
}
|
||||
return c.MinMaxEps
|
||||
}
|
||||
|
||||
// retrieveCrossModal 是跨模态并行检索的统一入口。
|
||||
//
|
||||
// 策略(两路并行,召回真正最相似的):
|
||||
// 1. 文本路:query 整段文本编码后查文档层(Doc.DenseVec 已融合其块的媒体向量),
|
||||
// 命中文档若持有媒体块,直接带上该块。
|
||||
// 2. 视觉路:query 经多模态模型文本编码 → 与媒体块向量比余弦
|
||||
// (QueryMediaScored),覆盖文本向量没写到的视觉内容。
|
||||
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
|
||||
// 同一媒体被两路同时命中视为双信号确认,额外加权。
|
||||
//
|
||||
// 多模态空间未配置时视觉路为空,退化为纯文本路(等价旧 docStore.Query)。
|
||||
func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
// 融合前各取 2× 余量,保证融合排序后 topK 仍有足够候选。
|
||||
per := topK * 2
|
||||
if per < 8 {
|
||||
per = 8
|
||||
}
|
||||
|
||||
// ---- 文本路 ----
|
||||
var textHits []CrossModalHit
|
||||
if a.docStore != nil {
|
||||
for _, dh := range a.docStore.QueryScored(query, per) {
|
||||
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
|
||||
// 命中文档若持有一等记忆块,把首个媒体块一并带上。
|
||||
if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
|
||||
if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil {
|
||||
hit.Media = it
|
||||
}
|
||||
}
|
||||
textHits = append(textHits, hit)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 视觉路(多模态文本编码 → 当前记忆层持有的媒体块)----
|
||||
var visualHits []CrossModalHit
|
||||
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.mediaStore != nil {
|
||||
qv, err := a.multimodalSpace.VectorizeDense(query)
|
||||
if err != nil {
|
||||
log.Printf("[crossmodal] 多模态文本编码失败: %v", err)
|
||||
} else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil {
|
||||
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
|
||||
} else {
|
||||
// 只有仍被某层记忆块持有的媒体才可召回:CAS 是全库字节存储,
|
||||
// 直接拿它的检索结果会把已无处可归的内容也从记忆里翻出来。
|
||||
held := a.heldMediaDigests()
|
||||
for _, h := range mh {
|
||||
if h.Item == nil || !held[h.Item.Digest] {
|
||||
continue
|
||||
}
|
||||
visualHits = append(visualHits, CrossModalHit{
|
||||
Media: h.Item, MediaScore: h.Score,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fuseCrossModal(textHits, visualHits, topK, cfg)
|
||||
}
|
||||
|
||||
// fuseCrossModal 把文本路与视觉路候选按各自归一化分融合排序。
|
||||
//
|
||||
// 归一化模板:两路分数尺度不可直接相加,先各自在路内 min-max 到 [0,1]:
|
||||
//
|
||||
// norm(x) = (x - min) / (max - min),max==min 时置 1
|
||||
//
|
||||
// 再加权求和:fused = wText·normText + wVisual·normVisual。同一媒体两路都命中
|
||||
// (经文档关联 + 视觉直接)时 DoubleHit,在加权分上再加双信号确认分。
|
||||
// 权重通过 CrossModalFusionConfig 按场景配置,不同模型/版本可按实测调整。
|
||||
func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
|
||||
norm := func(hits []CrossModalHit, pick func(CrossModalHit) float64) []float64 {
|
||||
out := make([]float64, len(hits))
|
||||
if len(hits) == 0 {
|
||||
return out
|
||||
}
|
||||
maxV, minV := pick(hits[0]), pick(hits[0])
|
||||
for _, h := range hits[1:] {
|
||||
v := pick(h)
|
||||
if v > maxV {
|
||||
maxV = v
|
||||
}
|
||||
if v < minV {
|
||||
minV = v
|
||||
}
|
||||
}
|
||||
for i, h := range hits {
|
||||
v := pick(h)
|
||||
if maxV-minV < cfg.minMaxEps() {
|
||||
out[i] = 1
|
||||
continue
|
||||
}
|
||||
out[i] = (v - minV) / (maxV - minV)
|
||||
}
|
||||
return out
|
||||
}
|
||||
textN := norm(textHits, func(h CrossModalHit) float64 { return h.DocScore })
|
||||
visualN := norm(visualHits, func(h CrossModalHit) float64 { return h.MediaScore })
|
||||
|
||||
byKey := make(map[string]*CrossModalHit)
|
||||
var keys []string
|
||||
key := func(h CrossModalHit) string {
|
||||
if h.Doc != nil {
|
||||
return "doc:" + h.Doc.ID
|
||||
}
|
||||
if h.Media != nil {
|
||||
return "media:" + h.Media.Digest
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 先并入视觉路(视觉媒体是独立实体)
|
||||
for i, h := range visualHits {
|
||||
k := key(h)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
clone := h
|
||||
clone.Fused = cfg.visualWeight() * visualN[i]
|
||||
byKey[k] = &clone
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// 再并入文本路:命中的文档是独立实体;带媒体的文档若其媒体 digest
|
||||
// 已在视觉路(双命中),合并到同一候选并标记 DoubleHit。
|
||||
for i, h := range textHits {
|
||||
if h.Doc == nil {
|
||||
continue
|
||||
}
|
||||
if h.Media != nil {
|
||||
if ex, ok := byKey["media:"+h.Media.Digest]; ok {
|
||||
ex.DoubleHit = true
|
||||
ex.Doc = h.Doc
|
||||
ex.Fused += cfg.textWeight()*textN[i] + cfg.doubleHitBonus()
|
||||
continue
|
||||
}
|
||||
}
|
||||
k := "doc:" + h.Doc.ID
|
||||
if ex, ok := byKey[k]; ok {
|
||||
ex.Doc = h.Doc
|
||||
ex.DoubleHit = false
|
||||
ex.Fused += cfg.textWeight() * textN[i]
|
||||
continue
|
||||
}
|
||||
clone := h
|
||||
clone.Fused = cfg.textWeight() * textN[i]
|
||||
byKey[k] = &clone
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
var merged []CrossModalHit
|
||||
for _, k := range keys {
|
||||
if c := byKey[k]; c != nil {
|
||||
merged = append(merged, *c)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(merged, func(i, j int) bool {
|
||||
if merged[i].DoubleHit != merged[j].DoubleHit {
|
||||
return merged[i].DoubleHit
|
||||
}
|
||||
return merged[i].Fused > merged[j].Fused
|
||||
})
|
||||
if len(merged) > topK {
|
||||
merged = merged[:topK]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
|
||||
// 文档行给出摘要;媒体行只给 MIME + 短 digest(不再有生成的描述)。
|
||||
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
if len(hits) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for i, h := range hits {
|
||||
marker := ""
|
||||
switch {
|
||||
case h.DoubleHit:
|
||||
marker = "(图文双命中)"
|
||||
case h.Doc != nil:
|
||||
marker = "(文本命中)"
|
||||
case h.Media != nil:
|
||||
marker = "(视觉命中)"
|
||||
}
|
||||
parts := []string{fmt.Sprintf("[%d]", i+1)}
|
||||
if h.Doc != nil {
|
||||
parts = append(parts, h.Doc.Summary)
|
||||
if h.Doc.Source != "" {
|
||||
parts = append(parts, fmt.Sprintf("(来源:%s)", h.Doc.Source))
|
||||
}
|
||||
}
|
||||
if h.Media != nil {
|
||||
if line := mediaLabel(h.Media); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("相关度:%.2f%s", h.Fused, marker))
|
||||
lines = append(lines, strings.Join(parts, " "))
|
||||
}
|
||||
return "【跨模态相关记忆】\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
@ -10,7 +10,6 @@ import (
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
)
|
||||
@ -184,7 +183,7 @@ func (a *Agent) archiveColdDocs() {
|
||||
if len(triples) == 0 {
|
||||
continue
|
||||
}
|
||||
ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0)
|
||||
ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, doc.Blocks)
|
||||
if err != nil {
|
||||
log.Printf("[agent] doc→graph archival error: %v", err)
|
||||
continue
|
||||
@ -203,64 +202,22 @@ func (a *Agent) archiveColdDocs() {
|
||||
"(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples))
|
||||
continue
|
||||
}
|
||||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
|
||||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks)
|
||||
|
||||
// 先销媒体引用再删文档:文档一旦从 docStore 消失,就再没有任何
|
||||
// 东西能告诉我们它曾经引用过哪些 digest,media_refs 里那条记录
|
||||
// 就永久悬空、引用计数永不归零,导致 blob 永远不会被 GC 回收。
|
||||
//
|
||||
// 但只有在引用**确实**转移到 graph_sentence 之后才能释放:
|
||||
// 图库里没有任何句子承载这些 digest 时释放旧引用,计数归零,
|
||||
// GC 会把内容当孤儿删掉。宁可留一条悬空引用(内容还在,可由
|
||||
// 后续一致性检查清理),也不能丢内容。
|
||||
refs, refErr := a.docMediaRefs(doc.ID)
|
||||
switch {
|
||||
case refErr != nil:
|
||||
log.Printf("[media] 查文档 %s 的媒体引用失败,保守不释放: %v", doc.ID, refErr)
|
||||
case len(refs) == 0:
|
||||
// 该文档本就没有媒体引用,无需释放。
|
||||
case mediaBound == 0:
|
||||
log.Printf("[media] 文档 %s 有 %d 个媒体引用但图库一个都没绑上,"+
|
||||
"保留引用以免 GC 删除内容(句子正文里可能没有可反解的短 digest)",
|
||||
doc.ID, len(refs))
|
||||
default:
|
||||
a.releaseDocMedia(doc.ID)
|
||||
// 文档持有的一等块写入 L3,并以 document --contains--> block 边关联;
|
||||
// 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。
|
||||
if len(doc.Blocks) > 0 {
|
||||
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks); bound != len(doc.Blocks) {
|
||||
log.Printf("[agent] doc→graph: %s 块迁移不完整 (%d/%d),保留文档待下轮重试",
|
||||
doc.ID, bound, len(doc.Blocks))
|
||||
continue
|
||||
}
|
||||
}
|
||||
a.docStore.Remove(doc.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// docMediaRefs 返回文档当前持有的媒体引用(nil store 时为空)。
|
||||
//
|
||||
// 单独取出来是为了让归档路径能在释放前先确认「有没有东西要释放」——
|
||||
// 没有引用时不必打日志,有引用但没绑上图库时必须保留。
|
||||
func (a *Agent) docMediaRefs(docID string) ([]string, error) {
|
||||
if a.mediaStore == nil || docID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return a.mediaStore.Refs(media.OwnerDocument, docID)
|
||||
}
|
||||
|
||||
// releaseDocMedia 注销文档持有的全部媒体引用。
|
||||
//
|
||||
// L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述
|
||||
// 文本里抽出的实体与关系,不再持有字节。媒体本身此时已完成使命:
|
||||
// 描述已经进了图库,blob 可以交给容量 GC 决定去留。
|
||||
func (a *Agent) releaseDocMedia(docID string) {
|
||||
if a.mediaStore == nil || docID == "" {
|
||||
return
|
||||
}
|
||||
n, err := a.mediaStore.DropOwner(media.OwnerDocument, docID)
|
||||
if err != nil {
|
||||
log.Printf("[media] 文档归档释放引用失败 (doc %s): %v", docID, err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[media] 文档 %s 入图库,释放 %d 个媒体引用(描述已留在图库)", docID, n)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 实体合并检测:GraphDB → LLM 裁决
|
||||
// ──────────────────────────────────────────────
|
||||
@ -480,14 +437,9 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
})
|
||||
}
|
||||
|
||||
// 媒体三元组:确定性产出,先于 NLP 提取。
|
||||
//
|
||||
// 媒体入 L3 曾完全依赖提取器碰巧从描述文本里提出合规三元组——实测
|
||||
// LLM 的 477 字图片描述只产出「水平 -分割-> 成」这类语法碎片,
|
||||
// obj 仅 1 字被 validEntityName 拒掉,整条媒体记忆就进不了图库
|
||||
//(阶段性表现是"时好时坏",取决于提取器运气)。媒体自身的
|
||||
// digest / mime / 描述都是确定的,直接建三元组而不经提取器。
|
||||
triples = append(triples, mediaTriplesFromText(doc.Content)...)
|
||||
// 媒体不再参与三元组:它作为一等块由 linkBlocksToDocument
|
||||
// 写入 L3 并以 document --contains--> block 边关联,
|
||||
// 不经过文本描述与 NLP 提取器。
|
||||
|
||||
// NLP 通用提取
|
||||
e := nlp.NewExtractor(nil)
|
||||
|
||||
@ -400,7 +400,7 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
}
|
||||
a.publishEvent(events.EventRawInput, rawPayload)
|
||||
|
||||
archived := a.context.Prune(cleanInput, a.maxContextSize-1, a.docStore)
|
||||
archived := a.pruneOnInput(evt, cleanInput)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
@ -437,9 +437,6 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
ToolResults: toolResults,
|
||||
}
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
||||
if s := a.mediaSummaryForEvent(turnEvt.Media); s != "" {
|
||||
turnEvt.Input = turnEvt.Input + "\n" + s
|
||||
}
|
||||
a.context.Append(turnEvt)
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
@ -525,3 +522,66 @@ func (a *Agent) drainInterrupts() []string {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pruneOnInput 按声明的上下文策略裁剪上下文,返回归档的事件数。
|
||||
//
|
||||
// 默认**不裁剪**:ContextPolicy 必须在注入点(payload 的 context_policy)
|
||||
// 或通道定义(ChannelDef.ContextPolicy)上显式声明为 prune 才会裁剪。
|
||||
//
|
||||
// 为什么把无条件裁剪改成需声明:裁剪会把低相关事件归档到文档记忆并从上下文里
|
||||
// 移走,是破坏性的。此前每条输入都裁一次,于是「谁把上下文裁了」在排查时无从
|
||||
// 得知;而插件注入的内容也会被不相关的内容挤掉。按来源/注入点声明后,触发条件
|
||||
// 是可枚举、可审计的。
|
||||
//
|
||||
// 查询向量取**清洗后**的输入(通道 Cleaner 的输出),与工具侧同一套语义:
|
||||
// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏,裁掉本该保留的事件。
|
||||
func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
|
||||
if a.context == nil || !a.pruneDeclared(evt) {
|
||||
return 0
|
||||
}
|
||||
topK := a.maxContextSize - 1
|
||||
if topK < 1 {
|
||||
topK = 1
|
||||
}
|
||||
return a.context.Prune(cleanInput, topK, a.docStore)
|
||||
}
|
||||
|
||||
// pruneDeclared 判定这次输入是否显式声明了裁剪。
|
||||
//
|
||||
// 优先级:注入点声明的(payload)> 通道声明的(ChannelDef)> 默认不裁剪。
|
||||
// 注入点是更窄的声明面,同一通道下的不同注入可以有不同意图。
|
||||
func (a *Agent) pruneDeclared(evt *agentIO.InputEvent) bool {
|
||||
if p, ok := evt.Payload["context_policy"].(string); ok && p != "" {
|
||||
return p == pubsdk.ContextPolicyPrune
|
||||
}
|
||||
if a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok {
|
||||
return chDef.ContextPolicy == pubsdk.ContextPolicyPrune
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanInputFor 解析这条输入在计算层应当使用的清洗文本。
|
||||
//
|
||||
// 优先级:注入点声明的 cleaner(payload.cleaner_name,引用某个已注册的通道
|
||||
// cleaner)> 按 source 查到的通道 cleaner > 原文。
|
||||
//
|
||||
// 声明的 cleaner 名字查不到时**记日志并回退**,而不是静默当没声明:
|
||||
// 注入是 fire-and-forget 的,插件那边看不到错误;至少要在内核日志里留下
|
||||
// 「你声明的清洗没生效」的痕迹,否则排查时只能看到「记忆里的内容很脏」。
|
||||
func (a *Agent) cleanInputFor(evt *agentIO.InputEvent, input string) string {
|
||||
if a.io == nil {
|
||||
return input
|
||||
}
|
||||
if name, ok := evt.Payload["cleaner_name"].(string); ok && name != "" {
|
||||
if chDef, ok := a.io.GetInputChannelDef(name); ok && chDef.Cleaner != nil {
|
||||
return chDef.Cleaner(input)
|
||||
}
|
||||
log.Printf("[agent] 注入声明了 cleaner_name=%q 但没有注册过该通道的 Cleaner,已回退", name)
|
||||
}
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
|
||||
return chDef.Cleaner(input)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
@ -3,339 +3,151 @@ package core
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
)
|
||||
|
||||
// L3 图库的媒体引用绑定。
|
||||
// L3 图库的媒体绑定。
|
||||
//
|
||||
// 设计定位(方案 A:只做引用,不建媒体实体节点):
|
||||
// 图库里的实体与关系全部来自**描述文本**的 NLP 提取——媒体描述经
|
||||
// mediaSummaryForEvent 进了 L0 事件的 Input,随归档进 L2 文档的 Content,
|
||||
// 蒸馏时提取器自然会从描述文字里抽出实体和关系。
|
||||
// 媒体在 L3 是一等记忆块(memory_blocks),以结构边与承载它的节点相连:
|
||||
// sentence --contains--> block(对话/三元组产生的记忆)
|
||||
// document --contains--> block(L2 文档归档进 L3)
|
||||
//
|
||||
// 为何不把媒体本身建成实体节点:节点名只能从描述里取,而描述会被重新生成
|
||||
// (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下
|
||||
// 多个语义模糊的节点。检索能力靠描述文本已经具备,多这类节点只是噪声。
|
||||
//
|
||||
// 那么图库侧还需要什么:**反查**。图库里的句子写着「[image a1b2c3d4e5f6]
|
||||
// 一张紫蓝红三色带图」,要能从这条句子找回那份字节。这就是
|
||||
// media_refs 的 graph_sentence owner 的用途,也是这一层唯一要做的事。
|
||||
// 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径:
|
||||
// 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。
|
||||
|
||||
// mediaDigestPattern 匹配事件摘要里的媒体标记 [<mime或kind> <短digest>]。
|
||||
// migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。
|
||||
//
|
||||
// 与 mediaSummaryForEvent 的输出格式对应。短 digest 是 12 位十六进制
|
||||
// (shortDigest 的截断长度),这里放宽到 8-64 位以容忍将来调整截断长度,
|
||||
// 以及有人手写了完整 digest 的情况。
|
||||
var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`)
|
||||
|
||||
// mediaMarkerPattern 完整拆解一条媒体标记及其后跟的描述,
|
||||
// 捕获组依次为:标签(mime 或 kind)、短 digest、该行剩余的描述文本。
|
||||
//
|
||||
// 与 mediaSummaryForEvent 的输出格式严格对应:
|
||||
//
|
||||
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||
//
|
||||
// 描述取到行尾而非贪婪到底:一条事件可能挂多个媒体,各占一行。
|
||||
var mediaMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
|
||||
|
||||
// mediaMarker 是从文档正文里解析出的一条媒体标记。
|
||||
type mediaMarker struct {
|
||||
label string // mime 或 kind,如 image/png
|
||||
shortDigest string
|
||||
description string
|
||||
raw string // 原始整段,用作三元组的 SentenceText
|
||||
}
|
||||
|
||||
// parseMediaMarkers 从文本里解析全部媒体标记。
|
||||
//
|
||||
// 为何需要它而不只是 extractMediaDigests:媒体入 L3 曾完全依赖 NLP 提取器
|
||||
// 碰巧从描述文本里提出合规三元组——实测 LLM 的 477 字图片描述只产出
|
||||
// 「水平 -分割-> 成」这种语法碎片,obj 仅 1 字被 validEntityName 拒掉,
|
||||
// 于是整条媒体记忆进不了图库。而媒体自身的信息(digest / mime / 描述)
|
||||
// 是确定的,不该受提取器运气支配。
|
||||
func parseMediaMarkers(text string) []mediaMarker {
|
||||
if text == "" {
|
||||
return nil
|
||||
// 旧数据里媒体是 type=Media 的普通实体(「图片 a1b2c3d4e5f6」),
|
||||
// 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以
|
||||
// sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。
|
||||
// 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。
|
||||
func (a *Agent) migrateLegacyGraphMedia() {
|
||||
if a.memory == nil || a.mediaStore == nil {
|
||||
return
|
||||
}
|
||||
ms := mediaMarkerPattern.FindAllStringSubmatch(text, -1)
|
||||
if len(ms) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(ms))
|
||||
var out []mediaMarker
|
||||
for _, m := range ms {
|
||||
d := m[2]
|
||||
if seen[d] {
|
||||
continue
|
||||
blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
|
||||
full, err := a.mediaStore.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
seen[d] = true
|
||||
out = append(out, mediaMarker{
|
||||
label: m[1],
|
||||
shortDigest: d,
|
||||
description: strings.TrimSpace(m[3]),
|
||||
raw: strings.TrimSpace(m[0]),
|
||||
})
|
||||
return a.blockFromDigest(full)
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err)
|
||||
return
|
||||
}
|
||||
if blocks > 0 || entities > 0 {
|
||||
log.Printf("[media] 旧媒体实体迁移完成: 新建 %d 个原生块,删除 %d 个描述式实体", blocks, entities)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mediaEntityName 是媒体在图库里的实体名。
|
||||
//
|
||||
// 形如「图片 a1b2c3d4e5f6」。刻意用 digest 而非描述文本构成名字:
|
||||
// 描述会被重新生成(换视觉模型、补描述),若名字取自描述,同一张图
|
||||
// 就会在图谱上留下多个节点。digest 不变则名字不变。
|
||||
// 长度也天然合规(validEntityName 要求 2–50 字符)。
|
||||
func mediaEntityName(label, shortDigest string) string {
|
||||
kind := "媒体"
|
||||
switch {
|
||||
case strings.HasPrefix(label, "image"):
|
||||
kind = "图片"
|
||||
case strings.HasPrefix(label, "audio"):
|
||||
kind = "音频"
|
||||
case strings.HasPrefix(label, "video"):
|
||||
kind = "视频"
|
||||
}
|
||||
return kind + " " + shortDigest
|
||||
}
|
||||
|
||||
// mediaTriplesFromText 为文本里的每条媒体标记产出确定的三元组。
|
||||
//
|
||||
// 这是媒体进 L3 的可靠路径:不经过 NLP 提取器,因此不受它对描述性文本
|
||||
// 提取能力的影响。每条媒体至少产出一条「<媒体实体> -内容-> <描述摘要>」,
|
||||
// 且 SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能
|
||||
// 反解到 digest——绑定从概率事件变成确定行为。
|
||||
//
|
||||
// 描述摘要截到 40 字:validEntityName 上限 50 字符,留出余量;
|
||||
// 图谱节点名过长会让可视化和实体合并都难以处理,完整描述留在
|
||||
// SentenceText 与 media 表里。
|
||||
func mediaTriplesFromText(text string) []memory.Triple {
|
||||
markers := parseMediaMarkers(text)
|
||||
if len(markers) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []memory.Triple
|
||||
for _, m := range markers {
|
||||
name := mediaEntityName(m.label, m.shortDigest)
|
||||
|
||||
// 类型三元组恒可产出,不依赖描述是否存在
|
||||
out = append(out, memory.Triple{
|
||||
Subject: name,
|
||||
SubjectType: "Media",
|
||||
Relation: "类型",
|
||||
Object: m.label,
|
||||
ObjectType: "MimeType",
|
||||
Confidence: 1.0,
|
||||
SentenceText: m.raw,
|
||||
})
|
||||
|
||||
desc := summarizeForEntity(m.description, 40)
|
||||
if desc == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, memory.Triple{
|
||||
Subject: name,
|
||||
SubjectType: "Media",
|
||||
Relation: "内容",
|
||||
Object: desc,
|
||||
ObjectType: "Description",
|
||||
Confidence: 1.0,
|
||||
SentenceText: m.raw,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// summarizeForEntity 把描述压成可作实体名的短串。
|
||||
//
|
||||
// 取首个句子边界之前的内容,再按 rune 截断——直接按字节截会切坏 UTF-8,
|
||||
// 图库里就会出现乱码实体名。空白与 Markdown 强调符号一并清掉,
|
||||
// 否则「**整体构成**」这类标记会进实体名。
|
||||
func summarizeForEntity(s string, maxRunes int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = strings.NewReplacer("**", "", "*", "", "\n", " ", "\t", " ").Replace(s)
|
||||
for _, sep := range []string{"。", ";", ",", ". ", "; "} {
|
||||
if i := strings.Index(s, sep); i > 0 {
|
||||
s = s[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
r := []rune(s)
|
||||
if len(r) > maxRunes {
|
||||
r = r[:maxRunes]
|
||||
}
|
||||
out := strings.TrimSpace(string(r))
|
||||
// 太短的残片(如单字)过不了 validEntityName,直接放弃比写进去更好
|
||||
if len([]rune(out)) < 2 {
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extractMediaDigests 从文本里找出所有媒体标记的 digest。
|
||||
//
|
||||
// 为何靠正则从文本反解,而不是让三元组结构携带 digest:三元组是 NLP
|
||||
// 提取器从纯文本产出的(nlp.ToMemoryTriple 只填 Subject/Relation/Object/
|
||||
// Confidence/SentenceText),提取链路上没有任何位置能塞进结构化的 digest。
|
||||
// 若要贯通就得改 internal/nlp 的整条数据流——而媒体标记本身就是我们
|
||||
// 自己按固定格式写进文本的,反解是这里最省的可靠做法。
|
||||
func extractMediaDigests(text string) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
matches := mediaDigestPattern.FindAllStringSubmatch(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(matches))
|
||||
var out []string
|
||||
for _, m := range matches {
|
||||
d := m[1]
|
||||
if seen[d] {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bindSentenceMedia 把句子文本里提到的媒体挂到对应的 sentences.id 上。
|
||||
//
|
||||
// sentenceIDs 来自 GraphDB.CommitWithMedia:句子文本 → sentences.id。
|
||||
// 只处理本次真正写入了 sentences 表的句子,避免给历史句子重复挂引用
|
||||
// (AddRef 幂等,重复挂不会涨计数,但白跑 SQL)。
|
||||
//
|
||||
// 返回实际绑定成功的引用数,这是调用方的安全依据:归档路径靠它判定
|
||||
// 「引用真的转移到图库了吗」,不能用「Commit 没报错」代替——Commit 会
|
||||
// 静默跳过实体名不合法(validEntityName 要求 2–50 字符)的三元组,
|
||||
// 于是「无错但一条也没写进去」是真实会发生的:LLM 生成的长描述提不出
|
||||
// 合规实体名,实测 456 字描述得到 0 entities 0 relations。
|
||||
func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) int {
|
||||
if a.mediaStore == nil || len(sentenceIDs) == 0 {
|
||||
// attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。
|
||||
// seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。
|
||||
func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock) int {
|
||||
if a.mediaStore == nil || a.memory == nil || sentenceID == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
bound := 0
|
||||
for text, sid := range sentenceIDs {
|
||||
if sid == 0 {
|
||||
for _, d := range digests {
|
||||
full, err := a.mediaStore.ResolvePrefix(d)
|
||||
if err != nil {
|
||||
log.Printf("[media] digest %s 无法解析: %v", d, err)
|
||||
continue
|
||||
}
|
||||
digests := extractMediaDigests(text)
|
||||
if len(digests) == 0 {
|
||||
b, ok := seed[full]
|
||||
if !ok {
|
||||
if b, ok = a.blockFromDigest(full); !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err)
|
||||
continue
|
||||
}
|
||||
ownerID := strconv.FormatInt(sid, 10)
|
||||
for _, short := range digests {
|
||||
// 文本里是短 digest,media_refs 的主键要完整 digest。
|
||||
// 补全失败(内容已被 GC 清掉、或前缀有歧义)就跳过——
|
||||
// 挂一条对不上的引用比不挂更糟:DropOwner 永远匹配不到它。
|
||||
full, err := a.mediaStore.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := a.mediaStore.AddRef(full, media.OwnerGraphSentence, ownerID); err != nil {
|
||||
log.Printf("[media] 句子引用绑定失败 (%s → sentence %s): %v", short, ownerID, err)
|
||||
continue
|
||||
}
|
||||
bound++
|
||||
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sentenceID, 10), "block", b.ID, "contains"); err != nil {
|
||||
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if bound > 0 {
|
||||
log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound)
|
||||
bound++
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
// sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。
|
||||
//
|
||||
// 存在的理由:媒体的绑定链是 SentenceText → sentences 表 → sentence_id →
|
||||
// media_refs。模型只知道 digest(从 memory_recall 的「关联媒体」或对话里的
|
||||
// 媒体标记读到),不该要求它自己按内核格式拼标记——格式写错的后果是引用
|
||||
// 静默挂不上,模型也无从察觉。
|
||||
//
|
||||
// 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。
|
||||
func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
return sentence
|
||||
// linkBlocksToDocument 把文档持有的块写入 L3,并建立
|
||||
// document --contains--> block 边。块的 ID 原样保留(迁移而非重建)。
|
||||
func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) int {
|
||||
if a.memory == nil || docID == "" || len(blocks) == 0 {
|
||||
return 0
|
||||
}
|
||||
present := make(map[string]bool)
|
||||
for _, d := range extractMediaDigests(sentence) {
|
||||
present[d] = true
|
||||
if err := a.memory.PutDocumentNode(docID, ""); err != nil {
|
||||
log.Printf("[media] 写入 L3 文档节点失败 (%s): %v", docID, err)
|
||||
return 0
|
||||
}
|
||||
|
||||
var add []string
|
||||
for _, d := range digests {
|
||||
if d == "" || present[shortDigest(d)] {
|
||||
if err := a.memory.PutMemoryBlocks(blocks); err != nil {
|
||||
log.Printf("[media] 写入 L3 记忆块失败 (doc %s): %v", docID, err)
|
||||
return 0
|
||||
}
|
||||
bound := 0
|
||||
for _, b := range blocks {
|
||||
if err := a.memory.AddMemoryBlockEdge("document", docID, "block", b.ID, "contains"); err != nil {
|
||||
log.Printf("[media] 文档→块边建立失败 (%s): %v", shortDigest(b.PayloadDigest), err)
|
||||
continue
|
||||
}
|
||||
// 模型给的多半是短 digest(它在上下文里看到的就是短的),补全成完整
|
||||
// digest 才能进 media_refs 主键。补不上就跳过:内容可能已被 GC 清掉。
|
||||
full, err := a.mediaStore.ResolvePrefix(d)
|
||||
if err != nil {
|
||||
log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err)
|
||||
continue
|
||||
}
|
||||
if line := a.mediaMarkerLine(full); line != "" {
|
||||
add = append(add, line)
|
||||
present[shortDigest(full)] = true
|
||||
}
|
||||
bound++
|
||||
}
|
||||
if len(add) == 0 {
|
||||
return sentence
|
||||
}
|
||||
if sentence == "" {
|
||||
return strings.Join(add, "\n")
|
||||
}
|
||||
return sentence + "\n" + strings.Join(add, "\n")
|
||||
return bound
|
||||
}
|
||||
|
||||
// docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。
|
||||
// commitTriplesWithMedia 提交三元组并把三元组显式携带的媒体变成 L3 一等块。
|
||||
//
|
||||
// 优先读 media_refs(权威:谁挂上去的就是谁),为空时退回解析正文标记——
|
||||
// 历史文档与经旧版路径写入的文档只有标记、没有引用。
|
||||
func (a *Agent) docMediaContext(docID, content string) string {
|
||||
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
|
||||
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
|
||||
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
|
||||
if a.memory == nil {
|
||||
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
||||
}
|
||||
if a.mediaStore == nil {
|
||||
return ""
|
||||
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
|
||||
return ec, rc, 0, cErr
|
||||
}
|
||||
digests, err := a.mediaStore.Refs(media.OwnerDocument, docID)
|
||||
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
|
||||
if err != nil {
|
||||
log.Printf("[media] 读取文档 %s 的媒体引用失败: %v", docID, err)
|
||||
return ec, rc, 0, err
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
for _, short := range extractMediaDigests(content) {
|
||||
full, err := a.mediaStore.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
digests = append(digests, full)
|
||||
byDigest := make(map[string]memory.MemoryBlock, len(seed))
|
||||
for _, b := range seed {
|
||||
if b.PayloadDigest != "" {
|
||||
byDigest[b.PayloadDigest] = b
|
||||
}
|
||||
}
|
||||
var lines []string
|
||||
for _, d := range digests {
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
lines = append(lines, line)
|
||||
for _, t := range triples {
|
||||
if len(t.MediaDigests) == 0 {
|
||||
continue
|
||||
}
|
||||
sid := sentenceIDs[t.SentenceText]
|
||||
if sid == 0 {
|
||||
continue
|
||||
}
|
||||
blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
return ec, rc, blocks, nil
|
||||
}
|
||||
|
||||
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
|
||||
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
|
||||
if a.memory == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return strings.Join(lines, ";")
|
||||
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
|
||||
}
|
||||
|
||||
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
|
||||
//
|
||||
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被
|
||||
// 容量 GC 淘汰。挂一条对不上的引用比不挂更糟——digest 进了 media_refs 主键,
|
||||
// 错了则 DropOwner 永远匹配不到它,那是一条永久泄漏的引用。
|
||||
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被删除。
|
||||
func (a *Agent) resolveMediaDigests(digests []string) []string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
return nil
|
||||
@ -357,66 +169,10 @@ func (a *Agent) resolveMediaDigests(digests []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。
|
||||
//
|
||||
// 与 releaseDocMedia 成对:文档归档进 L3 时释放,文档写入时绑定。
|
||||
// 只绑不放会让磁盘只增不减,只放不绑会让 GC 误删仍被引用的内容。
|
||||
func (a *Agent) bindDocMedia(docID string, digests []string) int {
|
||||
if a.mediaStore == nil || docID == "" || len(digests) == 0 {
|
||||
return 0
|
||||
}
|
||||
bound := 0
|
||||
for _, d := range digests {
|
||||
if err := a.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
|
||||
log.Printf("[media] 文档引用绑定失败 (%s → doc %s): %v", shortDigest(d), docID, err)
|
||||
continue
|
||||
}
|
||||
bound++
|
||||
}
|
||||
if bound > 0 {
|
||||
log.Printf("[media] 文档 %s 绑定 %d 个媒体引用", docID, bound)
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。
|
||||
//
|
||||
// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定,
|
||||
// 而不必各自记得多调一次 bindSentenceMedia。
|
||||
// mediaBound 是本次实际挂到 graph_sentence owner 上的引用数;归档路径靠它
|
||||
// 判定能否安全释放旧引用。媒体存储关闭时恒为 0(此时也没有引用需要释放)。
|
||||
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (entities, relations, mediaBound int, err error) {
|
||||
if a.memory == nil {
|
||||
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
||||
}
|
||||
// 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。
|
||||
if a.mediaStore == nil {
|
||||
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
|
||||
return ec, rc, 0, cErr
|
||||
}
|
||||
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
|
||||
if err != nil {
|
||||
return ec, rc, 0, err
|
||||
}
|
||||
return ec, rc, a.bindSentenceMedia(sentenceIDs), nil
|
||||
}
|
||||
|
||||
// RecallMediaForSentence 反查某条图库句子引用的媒体。
|
||||
//
|
||||
// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份字节
|
||||
// (若尚未被容量 GC 淘汰)。返回的是完整 digest,调用方用
|
||||
// mediaStore.Get 取内容、Stat 取描述与元数据。
|
||||
func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) {
|
||||
if a.mediaStore == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sentenceID, 10))
|
||||
}
|
||||
|
||||
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。
|
||||
//
|
||||
// 关系行本身不持有媒体,媒体挂在句子上(graph_sentence owner)。
|
||||
// 因此"这次召回涉及哪些媒体"必须经由关系 → 句子 → media_refs 这条路。
|
||||
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
|
||||
// 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。
|
||||
func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
|
||||
if len(relations) == 0 {
|
||||
return nil
|
||||
@ -434,21 +190,14 @@ func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
|
||||
}
|
||||
|
||||
// mediaContextForRelations 是 mediaContextForSentences 的关系入口。
|
||||
//
|
||||
// 单独包一层是因为两个调用点(自动注入的 buildMemoryContext 与显式的
|
||||
// memory_recall 工具)拿到的都是关系列表,不该各自重复"关系→句子"这步。
|
||||
func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
|
||||
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
|
||||
}
|
||||
|
||||
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
|
||||
//
|
||||
// 单独一条路径是因为 Indexer.BuildContext 刻意不返回关系
|
||||
// (Relations 恒为 nil,只给实体索引以省 token,细节留给 memory_recall)。
|
||||
// 于是自动注入拿不到 sentence_id,必须用命中的实体名再查一次关系。
|
||||
//
|
||||
// 这次额外查询只为取 sentence_id,深度固定 1:媒体是"这条记忆当时带的图",
|
||||
// 不需要顺着关系network 扩散——扩散只会带出无关媒体并挤占 token。
|
||||
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token),
|
||||
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
|
||||
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
|
||||
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
|
||||
return ""
|
||||
@ -464,24 +213,45 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
|
||||
return a.mediaContextForRelations(res.Relations)
|
||||
}
|
||||
|
||||
// mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词。
|
||||
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短 digest),供 doc_query 展示。
|
||||
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
|
||||
if a.mediaStore == nil || d == nil || len(d.Blocks) == 0 {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for _, b := range d.Blocks {
|
||||
it, err := a.mediaStore.Stat(b.PayloadDigest)
|
||||
if err != nil || it == nil {
|
||||
continue
|
||||
}
|
||||
if line := mediaLabel(it); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// mediaContextForSentences 给一组句子附上其持有的一等块标签。
|
||||
//
|
||||
// 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。
|
||||
// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息——
|
||||
// 描述永存而字节可能已被淘汰,两者状态不同。
|
||||
// 标签只含 MIME 与短 digest:图片按向量检索,标签的作用是告诉模型
|
||||
// "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。
|
||||
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
|
||||
if a.mediaStore == nil || len(sentenceIDs) == 0 {
|
||||
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for _, sid := range sentenceIDs {
|
||||
digests, err := a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
||||
if err != nil || len(digests) == 0 {
|
||||
blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||
if err != nil || len(blocks) == 0 {
|
||||
continue
|
||||
}
|
||||
var parts []string
|
||||
for _, d := range digests {
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
for _, b := range blocks {
|
||||
it, err := a.mediaStore.Stat(b.PayloadDigest)
|
||||
if err != nil || it == nil {
|
||||
continue
|
||||
}
|
||||
if line := mediaLabel(it); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -12,10 +13,54 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// L3 图库媒体引用测试。
|
||||
// L3 图库媒体绑定测试。
|
||||
//
|
||||
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份字节。
|
||||
// 因此测试的重点是「反查链路是否完整」以及「引用是否会悬空或误删」。
|
||||
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份媒体。
|
||||
// 媒体作为一等块进入 L3,以结构边与承载节点相连:
|
||||
//
|
||||
// sentence --contains--> block(对话/三元组产生的记忆)
|
||||
// document --contains--> block(L2 文档归档进 L3)
|
||||
//
|
||||
// 描述文本、marker 反解、由 marker 反推出的「媒体实体」全部已废弃,
|
||||
// 因此这些测试也不存在任何按描述检索的断言。
|
||||
|
||||
// attachBlockToSentence 提交一条句子并把媒体变成 L3 一等块。
|
||||
// 必须走真实提交:边要求两端都是真实图节点。
|
||||
func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (int64, memory.MemoryBlock) {
|
||||
t.Helper()
|
||||
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
|
||||
Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentenceText,
|
||||
}}, "test", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CommitWithMedia: %v", err)
|
||||
}
|
||||
sid := ids[sentenceText]
|
||||
if sid == 0 {
|
||||
t.Fatalf("拿不到句子 id: %q", sentenceText)
|
||||
}
|
||||
it, err := ms.Stat(digest)
|
||||
if err != nil || it == nil {
|
||||
t.Fatalf("Stat(%s): %v", shortDigest(digest), err)
|
||||
}
|
||||
b := memory.MemoryBlock{
|
||||
ID: fmt.Sprintf("blk_test_%d_%s", sid, shortDigest(digest)),
|
||||
Modality: memory.BlockImage,
|
||||
PayloadDigest: it.Digest,
|
||||
MIME: it.MIME,
|
||||
Size: it.Size,
|
||||
Width: it.Width,
|
||||
Height: it.Height,
|
||||
Vector: it.Vec,
|
||||
Fingerprint: it.VecModel,
|
||||
}
|
||||
if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
t.Fatalf("PutMemoryBlocks: %v", err)
|
||||
}
|
||||
if err := g.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
|
||||
t.Fatalf("AddMemoryBlockEdge: %v", err)
|
||||
}
|
||||
return sid, b
|
||||
}
|
||||
|
||||
func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
|
||||
t.Helper()
|
||||
@ -27,7 +72,7 @@ func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
|
||||
}
|
||||
t.Cleanup(func() { g.Close() })
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatalf("media.New: %v", err)
|
||||
}
|
||||
@ -36,41 +81,10 @@ func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
|
||||
return &Agent{memory: g, mediaStore: ms}, g, ms
|
||||
}
|
||||
|
||||
func TestExtractMediaDigests(t *testing.T) {
|
||||
// 与 mediaSummaryForEvent 的输出格式对应
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want []string
|
||||
}{
|
||||
{"事件摘要格式", "媒体内容:\n[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图", []string{"a1b2c3d4e5f6"}},
|
||||
{"kind 兜底格式", "[image abcdef0123456789] (未描述)", []string{"abcdef0123456789"}},
|
||||
{"一句多个", "[image aaaaaaaaaaaa] 图一;[image bbbbbbbbbbbb] 图二", []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb"}},
|
||||
{"去重", "[image cccccccccccc] x [image/png cccccccccccc] y", []string{"cccccccccccc"}},
|
||||
{"无标记", "普通句子,没有媒体", nil},
|
||||
{"空串", "", nil},
|
||||
// 非十六进制、过短的方括号内容不能误命中,否则会拿一个假前缀去 ResolvePrefix
|
||||
{"非 digest 方括号", "[注意] 这是普通标注 [TODO]", nil},
|
||||
{"过短", "[image abc] 太短", nil},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := extractMediaDigests(c.text)
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("%s: 得到 %v,期望 %v", c.name, got, c.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Fatalf("%s: 第 %d 个得到 %q,期望 %q", c.name, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) {
|
||||
_, g, _ := newGraphMediaAgent(t)
|
||||
|
||||
sentence := "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图"
|
||||
sentence := "这张图是紫蓝红三色带。"
|
||||
triples := []memory.Triple{{
|
||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
||||
SentenceText: sentence,
|
||||
@ -123,7 +137,7 @@ func TestCommit_StillWorksAfterRefactor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
||||
func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) {
|
||||
// 整层的核心断言:写入 → 提交 → 反查取回原始字节
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
@ -132,18 +146,20 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
short := shortDigest(digest)
|
||||
|
||||
sentence := "[image/png " + short + "] 一张紫蓝红三色带图"
|
||||
sentence := "用户发来一张紫蓝红三色带图。"
|
||||
triples := []memory.Triple{{
|
||||
Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence,
|
||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
||||
SentenceText: sentence,
|
||||
MediaDigests: []string{digest[:12]}, // 模型手里通常只有短 digest
|
||||
}}
|
||||
|
||||
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil {
|
||||
if _, _, bound, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if bound != 1 {
|
||||
t.Fatalf("应绑定 1 个块,实际 %d", bound)
|
||||
}
|
||||
|
||||
// 找到句子 id
|
||||
ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -153,15 +169,15 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
||||
t.Fatal("拿不到句子 id")
|
||||
}
|
||||
|
||||
// 反查:从句子取回 digest,再取回字节
|
||||
digests, err := a.RecallMediaForSentence(sid)
|
||||
// 反查:从句子取回一等块,再取回字节
|
||||
blocks, err := a.RecallBlocksForSentence(sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(digests) != 1 || digests[0] != digest {
|
||||
t.Fatalf("反查应得完整 digest %s,实际 %v", shortDigest(digest), digests)
|
||||
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
|
||||
t.Fatalf("反查应得完整 digest %s,实际 %+v", shortDigest(digest), blocks)
|
||||
}
|
||||
got, err := ms.Get(digests[0])
|
||||
got, err := ms.Get(blocks[0].PayloadDigest)
|
||||
if err != nil {
|
||||
t.Fatalf("取回内容失败: %v", err)
|
||||
}
|
||||
@ -169,41 +185,89 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
||||
t.Fatal("取回的内容与写入不一致")
|
||||
}
|
||||
|
||||
// 引用计数非零 → GC 不会清它
|
||||
if _, _, err := ms.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 块仍被 L3 持有 → 内容应仍可读
|
||||
if _, err := ms.Get(digest); err != nil {
|
||||
t.Fatalf("被图库句子引用的内容不该被 GC 清掉: %v", err)
|
||||
t.Fatalf("被 L3 记忆块持有的内容不该被清除: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) {
|
||||
// 文本里的 digest 在库里不存在时必须跳过,不能挂一条对不上的引用——
|
||||
// 那条引用 DropOwner 永远匹配不到,会永久占着计数。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
sentence := "[image/png deadbeefdead] 一张不存在的图"
|
||||
ids := map[string]int64{sentence: 42}
|
||||
a.bindSentenceMedia(ids)
|
||||
|
||||
refs, err := ms.Refs(media.OwnerGraphSentence, "42")
|
||||
func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) {
|
||||
// digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
|
||||
a, g, _ := newGraphMediaAgent(t)
|
||||
if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil); n != 0 {
|
||||
t.Fatalf("无法补全的 digest 不该建块,实际绑定 %d", n)
|
||||
}
|
||||
blocks, err := g.BlocksForNode("sentence", "42")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 0 {
|
||||
t.Fatalf("无法补全的 digest 不该挂引用,实际 %v", refs)
|
||||
if len(blocks) != 0 {
|
||||
t.Fatalf("不该有块,实际 %+v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSentenceMedia_NilStoreNoop(t *testing.T) {
|
||||
func TestAttachBlocksToSentence_NilStoreNoop(t *testing.T) {
|
||||
a := &Agent{}
|
||||
a.bindSentenceMedia(map[string]int64{"[image aaaaaaaaaaaa] x": 1})
|
||||
if got, err := a.RecallMediaForSentence(1); err != nil || got != nil {
|
||||
if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil); n != 0 {
|
||||
t.Fatalf("媒体关闭时应静默无操作,实际 %d", n)
|
||||
}
|
||||
if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil {
|
||||
t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) {
|
||||
// L2→L3 迁移必须保持块身份:同一个块换层,而不是另建一个同内容的新块。
|
||||
a, g, ms := newGraphMediaAgent(t)
|
||||
digest, _ := ms.Put([]byte("seed-img"), media.Item{MIME: "image/png"})
|
||||
seedBlock, ok := a.blockFromDigest(digest)
|
||||
if !ok {
|
||||
t.Fatal("blockFromDigest 失败")
|
||||
}
|
||||
|
||||
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
|
||||
Subject: "迁移", Relation: "包含", Object: "媒体", SentenceText: "迁移测试句。",
|
||||
}}, "seed", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid := ids["迁移测试句。"]
|
||||
|
||||
byDigest := map[string]memory.MemoryBlock{digest: seedBlock}
|
||||
if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest); n != 1 {
|
||||
t.Fatalf("应绑定 1 个块,实际 %d", n)
|
||||
}
|
||||
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blocks) != 1 || blocks[0].ID != seedBlock.ID {
|
||||
t.Fatalf("块身份应保持为 %s,实际 %+v", seedBlock.ID, blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkBlocksToDocument_CreatesDocumentNodeEdge(t *testing.T) {
|
||||
// 文档归档进 L3:块原样迁入,document --contains--> block 边建立。
|
||||
a, g, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, _ := ms.Put([]byte("doc-img"), media.Item{MIME: "image/png"})
|
||||
b, ok := a.blockFromDigest(digest)
|
||||
if !ok {
|
||||
t.Fatal("blockFromDigest 失败")
|
||||
}
|
||||
|
||||
if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}); n != 1 {
|
||||
t.Fatalf("应建立 1 条文档→块边,实际 %d", n)
|
||||
}
|
||||
blocks, err := g.BlocksForNode("document", "doc_42")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blocks) != 1 || blocks[0].ID != b.ID {
|
||||
t.Fatalf("文档应持有块 %s,实际 %+v", b.ID, blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
|
||||
// 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致
|
||||
dir := t.TempDir()
|
||||
@ -216,7 +280,7 @@ func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
|
||||
a := &Agent{memory: g}
|
||||
ec, rc, _, err := a.commitTriplesWithMedia([]memory.Triple{
|
||||
{Subject: "张三", Relation: "喜欢", Object: "咖啡"},
|
||||
}, "s1", 0)
|
||||
}, "s1", 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -225,69 +289,108 @@ func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDocMedia_DropsRefsSoGCCanReclaim(t *testing.T) {
|
||||
// L2→L3 那一跳留下的泄漏:文档被 Remove 但引用没销,
|
||||
// 引用计数永不归零,blob 永远不会被 GC 回收。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerDocument, "doc_1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 释放前 GC 清不掉
|
||||
if _, _, err := ms.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ms.Stat(digest); err != nil {
|
||||
t.Fatal("有文档引用时不该被清")
|
||||
}
|
||||
|
||||
a.releaseDocMedia("doc_1")
|
||||
|
||||
if refs, _ := ms.Refs(media.OwnerDocument, "doc_1"); len(refs) != 0 {
|
||||
t.Fatalf("释放后不该还有文档引用,实际 %v", refs)
|
||||
}
|
||||
// 现在 GC 能回收了
|
||||
removed, _, err := ms.GC(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("释放引用后 GC 应能回收,实际清理 %d 条", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaContextForSentences(t *testing.T) {
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
a, g, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, "7"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest)
|
||||
|
||||
out := a.mediaContextForSentences([]int64{7, 8})
|
||||
out := a.mediaContextForSentences([]int64{sid, sid + 100})
|
||||
if out == "" {
|
||||
t.Fatal("应产出媒体说明")
|
||||
}
|
||||
if !contains(out, "句子 #7") || !contains(out, "一张紫蓝红三色带图") {
|
||||
if !contains(out, fmt.Sprintf("句子 #%d", sid)) || !contains(out, shortDigest(digest)) {
|
||||
t.Fatalf("说明内容不对: %q", out)
|
||||
}
|
||||
// 8 号句子没引用媒体,不该出现
|
||||
if contains(out, "句子 #8") {
|
||||
// 说明只含 MIME 与短 digest,不含任何生成的描述
|
||||
if contains(out, "紫蓝红") {
|
||||
t.Fatalf("说明里不该有描述文本(描述式索引已废弃): %q", out)
|
||||
}
|
||||
// 无引用的句子不该出现
|
||||
if contains(out, fmt.Sprintf("句子 #%d", sid+100)) {
|
||||
t.Fatalf("无引用的句子不该出现: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
|
||||
// L3 检索接线回归:媒体作为一等块进了图库,agent 必须拿得出来。
|
||||
a, g, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest)
|
||||
|
||||
// 命中的关系挂着该句子 → 应产出媒体说明
|
||||
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: sid}})
|
||||
if out == "" {
|
||||
t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了")
|
||||
}
|
||||
if !contains(out, shortDigest(digest)) {
|
||||
t.Errorf("媒体说明里应含短 digest 供反查: %q", out)
|
||||
}
|
||||
|
||||
// 没挂媒体的关系不该产出噪声
|
||||
if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" {
|
||||
t.Errorf("无媒体的句子不该产出说明: %q", out)
|
||||
}
|
||||
if out := a.mediaContextForRelations(nil); out != "" {
|
||||
t.Errorf("空关系不该产出说明: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
|
||||
// buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。
|
||||
// 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。
|
||||
a, graph, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sentence := "用户发来的图片。"
|
||||
sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{
|
||||
Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence,
|
||||
}}, "auto", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid := sids[sentence]
|
||||
if sid == 0 {
|
||||
t.Fatal("拿不到句子 id")
|
||||
}
|
||||
if err := graph.PutMemoryBlocks([]memory.MemoryBlock{{
|
||||
ID: "blk_auto_1", Modality: memory.BlockImage,
|
||||
PayloadDigest: digest, MIME: "image/png",
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := graph.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", "blk_auto_1", "contains"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.indexer = memory.NewIndexer(graph)
|
||||
if err := a.indexer.Sync(); err != nil {
|
||||
t.Fatalf("indexer sync: %v", err)
|
||||
}
|
||||
|
||||
out := a.buildMemoryContext("测试图片", 0)
|
||||
if out == "" {
|
||||
t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入")
|
||||
}
|
||||
if !contains(out, "【关联媒体】") {
|
||||
t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out)
|
||||
}
|
||||
if !contains(out, shortDigest(digest)) {
|
||||
t.Errorf("媒体段里应含短 digest: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "m"), 0)
|
||||
ms, err := media.New(filepath.Join(dir, "m"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -313,23 +416,20 @@ func TestResolvePrefix(t *testing.T) {
|
||||
if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil {
|
||||
t.Fatal("不存在的前缀应报错")
|
||||
}
|
||||
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用
|
||||
fake := ""
|
||||
for i := 0; i < 64; i++ {
|
||||
fake += "0"
|
||||
}
|
||||
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿块
|
||||
fake := strings.Repeat("0", 64)
|
||||
if _, err := ms.ResolvePrefix(fake); err == nil {
|
||||
t.Fatal("不存在的完整 digest 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
||||
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容。
|
||||
// 前缀歧义视为错误而非"取第一个":挂错块会让内容被误删。
|
||||
// 构造歧义需要两个同前缀 digest——sha256 无法人为构造,
|
||||
// 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」:
|
||||
// 因此这里退而验证「12 位前缀在大量样本下的行为是确定的」:
|
||||
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "m"), 0)
|
||||
ms, err := media.New(filepath.Join(dir, "m"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -347,7 +447,6 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
||||
for _, d := range digests {
|
||||
got, err := ms.ResolvePrefix(d[:12])
|
||||
if err != nil {
|
||||
// 报歧义是可接受结果;静默取错才是缺陷
|
||||
if !contains(err.Error(), "歧义") {
|
||||
t.Fatalf("非歧义错误: %v", err)
|
||||
}
|
||||
@ -361,15 +460,11 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
||||
|
||||
func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
// 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities
|
||||
// 0 relations),文档不能删、媒体引用不能释放。
|
||||
//
|
||||
// 该缺陷曾真实发生:LLM 生成的 456 字图片描述提不出合规实体名
|
||||
//(validEntityName 要求 2–50 字符),archiveColdDocs 只检查
|
||||
// len(triples) > 0 就释放引用并删文档 → GC 清掉 blob → 图片与描述全丢。
|
||||
// 0 relations),文档不能删、其持有的块不能丢。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"))
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -383,24 +478,12 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 精确构造「三元组非空 + Commit 全部拒绝」这个状态。
|
||||
//
|
||||
// 用超长 Source 而不是指望 NLP 提取器:docToTriples 在
|
||||
// Source != "context_archived" 时会写一条 {文档 -来源-> Source},
|
||||
// Source 超过 validEntityName 的 50 字符上限 → Commit 静默跳过
|
||||
// → len(triples)==1 但 ec=0 rc=0。构造是确定的,不依赖提取器的
|
||||
// 具体行为(提取器行为随版本变化,测试不该押在它身上)。
|
||||
//
|
||||
// 正文里刻意**不放**媒体标记:mediaTriplesFromText 会为标记产出
|
||||
// 合规的「图片 <digest>」三元组,那样 ec/rc 就不为 0,这个用例
|
||||
// 也就测不到「全被拒绝」这个状态了。媒体引用直接用 AddRef 挂上,
|
||||
// 模拟「文档持有媒体但正文的媒体标记已在清洗中丢失」这一情形——
|
||||
// 那正是最危险的组合:有引用要释放,却没有句子能承载它。
|
||||
longSource := strings.Repeat("超长来源名", 20) // 100 字,远超 50 字符上限
|
||||
// Summary 也必须超长:docToTriples 会为合理 summary 写一条
|
||||
// {文档 -主题-> summary},那条能通过校验,ec/rc 就不为 0 了。
|
||||
// 这里要的是「三元组全部被拒」这一个状态。
|
||||
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字,触发长度门槛被跳过
|
||||
// 精确构造「三元组非空 + Commit 全部拒绝」这个状态:
|
||||
// Source/Summary 都超过 validEntityName 的 50 字符上限,
|
||||
// 于是 docToTriples 产出的两条元数据三元组都被跳过。
|
||||
longSource := strings.Repeat("超长来源名", 20) // 100 字
|
||||
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字触发长度门槛被跳过
|
||||
it, _ := ms.Stat(digest)
|
||||
doc := &document.Doc{
|
||||
ID: "doc_keep",
|
||||
Summary: longSummary,
|
||||
@ -409,6 +492,8 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
CreatedAt: time.Now().Add(-200 * time.Hour),
|
||||
LastAccess: time.Now().Add(-200 * time.Hour),
|
||||
AccessCount: 0,
|
||||
Blocks: []memory.MemoryBlock{{ID: "blk_keep_1", Modality: memory.BlockImage,
|
||||
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
|
||||
}
|
||||
if err := ds.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
@ -422,69 +507,198 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
d.AccessCount = 0
|
||||
}
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerDocument, doc.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.archiveColdDocs()
|
||||
|
||||
// 关键断言三连:内容在、引用在、文档在
|
||||
// 关键断言:内容在、块在、文档在
|
||||
if _, err := ms.Get(digest); err != nil {
|
||||
t.Fatalf("图库未写入任何实体/关系,内容却丢了: %v", err)
|
||||
}
|
||||
refs, err := ms.Refs(media.OwnerDocument, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
held := false
|
||||
for _, d := range ds.RecentDocs(10) {
|
||||
if d.ID == doc.ID && len(d.Blocks) > 0 {
|
||||
held = true
|
||||
}
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
t.Error("引用被释放了——图库没有句子承载它,释放后 GC 会删掉内容")
|
||||
}
|
||||
if removed, _, err := ms.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if _, err := ms.Stat(digest); err != nil {
|
||||
t.Fatalf("GC(清 %d 条) 删掉了本该保留的内容", removed)
|
||||
if !held {
|
||||
t.Error("文档或块被释放了——图库没有句子承载它,内容会被删除")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitTriplesWithMedia_ReportsBoundCount(t *testing.T) {
|
||||
// mediaBound 必须反映真实绑定数:归档路径靠它决定能否释放旧引用。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) {
|
||||
// 归档成功时块必须迁进 L3 并以 document --contains--> block 关联,
|
||||
// 然后文档才被删除(迁移而非复制/引用保活)。
|
||||
a, g, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
dir := t.TempDir()
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ds.Stop()
|
||||
a.docStore = ds
|
||||
a.embedder = memory.NewStaticEmbedder()
|
||||
|
||||
digest, _ := ms.Put([]byte("archived-image"), media.Item{MIME: "image/png"})
|
||||
it, _ := ms.Stat(digest)
|
||||
doc := &document.Doc{
|
||||
ID: "doc_arch",
|
||||
Summary: "带图的冷文档",
|
||||
Content: "张三把三色带图交给了李四。",
|
||||
Source: "manual",
|
||||
Blocks: []memory.MemoryBlock{{ID: "blk_arch_1", Modality: memory.BlockImage,
|
||||
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
|
||||
}
|
||||
if err := ds.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range ds.RecentDocs(10) {
|
||||
if d.ID == doc.ID {
|
||||
d.LastAccess = time.Now().Add(-200 * time.Hour)
|
||||
d.AccessCount = 0
|
||||
}
|
||||
}
|
||||
a.archiveColdDocs()
|
||||
|
||||
if d := ds.Get("doc_arch"); d != nil {
|
||||
t.Fatal("块已迁入 L3,文档应被删除")
|
||||
}
|
||||
blocks, err := g.BlocksForNode("document", "doc_arch")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
short := shortDigest(digest)
|
||||
if len(blocks) != 1 || blocks[0].ID != "blk_arch_1" {
|
||||
t.Fatalf("L3 文档节点应持有原块(身份不变),实际 %+v", blocks)
|
||||
}
|
||||
if _, err := ms.Get(digest); err != nil {
|
||||
t.Fatalf("块被 L3 持有,内容应仍可读: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 句子含可反解的短 digest → 应绑定 1 个
|
||||
_, _, bound, err := a.commitTriplesWithMedia([]memory.Triple{{
|
||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
||||
SentenceText: "[image/png " + short + "] 一张三色带图",
|
||||
}}, "s1", 0)
|
||||
func TestMigrateLegacyMediaEntities(t *testing.T) {
|
||||
// 旧数据:媒体被伪装成 type=Media 的实体,靠描述文本当索引。
|
||||
// 迁移必须把它还原成原生块(挂回原句子)并删掉旧实体与描述关系。
|
||||
_, g, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, _ := ms.Put([]byte("legacy-img"), media.Item{MIME: "image/png"})
|
||||
sentence := "老数据里的三色带图 [image/png " + digest[:12] + "]"
|
||||
// 直接构造旧的实体/关系形态(不走已删除的 marker 代码)。
|
||||
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
|
||||
Subject: "图片 " + digest[:12],
|
||||
SubjectType: "Media",
|
||||
Relation: "内容",
|
||||
Object: "三色带的描述文本",
|
||||
ObjectType: "Description",
|
||||
SentenceText: sentence,
|
||||
}}, "legacy", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bound != 1 {
|
||||
t.Fatalf("应绑定 1 个媒体引用,实际 %d", bound)
|
||||
sid := ids[sentence]
|
||||
if sid == 0 {
|
||||
t.Fatal("拿不到句子 id")
|
||||
}
|
||||
|
||||
// 句子无 digest → 绑定 0 个
|
||||
_, _, bound2, err := a.commitTriplesWithMedia([]memory.Triple{{
|
||||
Subject: "张三", Relation: "喜欢", Object: "咖啡",
|
||||
SentenceText: "张三喜欢咖啡",
|
||||
}}, "s2", 0)
|
||||
blocks, entities, err := g.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
|
||||
full, err := ms.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
it, err := ms.Stat(full)
|
||||
if err != nil {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
return memory.MemoryBlock{
|
||||
ID: "blk_legacy_" + short, Modality: memory.BlockImage,
|
||||
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size,
|
||||
}, true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bound2 != 0 {
|
||||
t.Fatalf("无媒体标记的句子不该绑定引用,实际 %d", bound2)
|
||||
if blocks != 1 || entities != 1 {
|
||||
t.Fatalf("应迁移 1 块 / 删 1 实体,实际 %d / %d", blocks, entities)
|
||||
}
|
||||
|
||||
// 旧媒体实体与描述关系必须消失
|
||||
res, err := g.Recall([]string{"图片 " + digest[:12]}, nil, 2, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range res.Entities {
|
||||
if e.Type == "Media" {
|
||||
t.Fatalf("旧媒体实体仍存在: %+v", e)
|
||||
}
|
||||
}
|
||||
// 块必须挂回原句子
|
||||
got, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].PayloadDigest != digest {
|
||||
t.Fatalf("句子应持有原生块,实际 %+v", got)
|
||||
}
|
||||
|
||||
// 幂等:再跑一遍不应重复建块
|
||||
blocks2, entities2, err := g.MigrateLegacyMediaEntities(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if blocks2 != 0 || entities2 != 0 {
|
||||
t.Fatalf("无 resolver 时应空操作,实际 %d / %d", blocks2, entities2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOrphanedSentences_KeepsBlockBackedSentences(t *testing.T) {
|
||||
// 旧媒体实体被删除后,承载它的句子可能再无关系引用,
|
||||
// 但它还挂着媒体块——清理孤儿句子时不能把它删掉。
|
||||
a, g, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, _ := ms.Put([]byte("orphan-img"), media.Item{MIME: "image/png"})
|
||||
sentence := "只靠媒体块存活的句子。"
|
||||
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
|
||||
Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentence,
|
||||
}}, "orphan", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid := ids[sentence]
|
||||
|
||||
b, ok := a.blockFromDigest(digest)
|
||||
if !ok {
|
||||
t.Fatal("blockFromDigest 失败")
|
||||
}
|
||||
if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := g.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 解除关系引用,句子只剩块边
|
||||
res, err := g.Recall([]string{"媒体载体"}, nil, 2, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range res.Relations {
|
||||
if err := g.ClearSentenceID(r.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := g.CleanupOrphanedSentences(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("承载媒体块的句子被误删,块反查失败: %+v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentenceIDsFromRelations(t *testing.T) {
|
||||
// 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳,
|
||||
// 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子,
|
||||
// 拿 0 去查 media_refs 会命中一个不存在的 owner。
|
||||
// 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子。
|
||||
rels := []memory.Relation{
|
||||
{ID: 1, SentenceID: 5},
|
||||
{ID: 2, SentenceID: 0}, // 无句子
|
||||
@ -503,203 +717,45 @@ func TestSentenceIDsFromRelations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
|
||||
// L3 检索接线回归:媒体描述进了图库,agent 必须拿得出来。
|
||||
//
|
||||
// 第四层做完了"存和反查的能力"(RecallMediaForSentence /
|
||||
// mediaContextForSentences),但那两个函数一度没有任何调用方——
|
||||
// 媒体能进 L3,进去之后 agent 检索不到。这个测试守住那条接线。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) {
|
||||
// 文档持有的一等块把内容钉住;文档被删后块随之消失,内容才可回收。
|
||||
_, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
|
||||
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
|
||||
dir := t.TempDir()
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, "5"); err != nil {
|
||||
defer ds.Stop()
|
||||
|
||||
it, _ := ms.Stat(digest)
|
||||
doc := &document.Doc{
|
||||
ID: "doc_1", Summary: "带图的文档", Content: "正文",
|
||||
Blocks: []memory.MemoryBlock{{ID: "blk_doc_1", Modality: memory.BlockImage,
|
||||
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
|
||||
}
|
||||
if err := ds.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 命中的关系挂着 5 号句子 → 应产出媒体说明
|
||||
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: 5}})
|
||||
if out == "" {
|
||||
t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了")
|
||||
}
|
||||
if !contains(out, "一张紫蓝红三色带图") {
|
||||
t.Errorf("媒体说明里应含描述文本: %q", out)
|
||||
}
|
||||
if !contains(out, shortDigest(digest)) {
|
||||
t.Errorf("媒体说明里应含短 digest 供反查: %q", out)
|
||||
// 文档仍持有块 → 内容在
|
||||
if _, err := ms.Stat(digest); err != nil {
|
||||
t.Fatal("有文档块持有内容时不该被清")
|
||||
}
|
||||
|
||||
// 没挂媒体的关系不该产出噪声
|
||||
if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" {
|
||||
t.Errorf("无媒体的句子不该产出说明: %q", out)
|
||||
// 删除文档 → 一并删除其内容(与文本块一致:删块即删内容)
|
||||
ds.Remove(doc.ID)
|
||||
if blocks := ds.Blocks(); len(blocks) != 0 {
|
||||
t.Fatalf("删除文档后不该还有块,实际 %+v", blocks)
|
||||
}
|
||||
if out := a.mediaContextForRelations(nil); out != "" {
|
||||
t.Errorf("空关系不该产出说明: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
|
||||
// buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。
|
||||
// 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。
|
||||
a, graph, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.Describe(digest, "自动注入用的测试图", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sentence := "用户发来的图片 [image/png " + shortDigest(digest) + "] 自动注入用的测试图"
|
||||
sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{
|
||||
Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence,
|
||||
}}, "auto", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid := sids[sentence]
|
||||
if sid == 0 {
|
||||
t.Fatal("拿不到句子 id")
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, strconv.FormatInt(sid, 10)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.indexer = memory.NewIndexer(graph)
|
||||
if err := a.indexer.Sync(); err != nil {
|
||||
t.Fatalf("indexer sync: %v", err)
|
||||
}
|
||||
|
||||
out := a.buildMemoryContext("测试图片", 0)
|
||||
if out == "" {
|
||||
t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入")
|
||||
}
|
||||
if !contains(out, "【关联媒体】") {
|
||||
t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out)
|
||||
}
|
||||
if !contains(out, "自动注入用的测试图") {
|
||||
t.Errorf("媒体段里应含描述文本: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaMarkers(t *testing.T) {
|
||||
// 与 mediaSummaryForEvent 的输出格式严格对应
|
||||
text := "用户发来图片\n媒体内容:\n" +
|
||||
"[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图\n" +
|
||||
"[audio/wav bbbbccccdddd] 一段三秒的钢琴声\n" +
|
||||
"[image/png a1b2c3d4e5f6] 重复的同一张图"
|
||||
|
||||
ms := parseMediaMarkers(text)
|
||||
if len(ms) != 2 {
|
||||
t.Fatalf("应解析出 2 条去重后的标记,实际 %d: %+v", len(ms), ms)
|
||||
}
|
||||
if ms[0].label != "image/png" || ms[0].shortDigest != "a1b2c3d4e5f6" {
|
||||
t.Errorf("第一条解析错误: %+v", ms[0])
|
||||
}
|
||||
if ms[0].description != "一张紫蓝红三色带图" {
|
||||
t.Errorf("描述应取到行尾且不跨行: %q", ms[0].description)
|
||||
}
|
||||
if ms[1].label != "audio/wav" {
|
||||
t.Errorf("第二条 label 错误: %+v", ms[1])
|
||||
}
|
||||
// raw 用作 SentenceText,必须含 digest 才能被 bindSentenceMedia 反解
|
||||
if !contains(ms[0].raw, "a1b2c3d4e5f6") {
|
||||
t.Errorf("raw 必须含 digest: %q", ms[0].raw)
|
||||
}
|
||||
if n := parseMediaMarkers("没有任何标记的普通文本"); n != nil {
|
||||
t.Errorf("无标记应返回 nil,实际 %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaEntityName(t *testing.T) {
|
||||
// 实体名必须由 digest 而非描述构成:描述会被重新生成,
|
||||
// 若名字取自描述,同一张图会在图谱上留下多个节点。
|
||||
cases := []struct{ label, digest, want string }{
|
||||
{"image/png", "a1b2c3d4e5f6", "图片 a1b2c3d4e5f6"},
|
||||
{"audio/wav", "bbbbccccdddd", "音频 bbbbccccdddd"},
|
||||
{"video/mp4", "ccccddddeeee", "视频 ccccddddeeee"},
|
||||
{"application/octet-stream", "ddddeeeeffff", "媒体 ddddeeeeffff"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := mediaEntityName(c.label, c.digest)
|
||||
if got != c.want {
|
||||
t.Errorf("mediaEntityName(%q,%q) = %q,期望 %q", c.label, c.digest, got, c.want)
|
||||
}
|
||||
// 必须过 validEntityName 的 2–50 字符门槛,否则 Commit 会静默跳过
|
||||
if n := len([]rune(got)); n < 2 || n > 50 {
|
||||
t.Errorf("实体名长度 %d 不在 2–50 之间: %q", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeForEntity(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"一张紫蓝红三色带图。还有更多内容。", "一张紫蓝红三色带图"},
|
||||
{"**整体构成**:正方形画布", "整体构成:正方形画布"}, // Markdown 强调符被清掉
|
||||
{"", ""},
|
||||
{"短", ""}, // 单字过不了 validEntityName,宁可不写
|
||||
// 无句子边界时按 rune 截到 40(不是按字节,否则切坏 UTF-8 会在图库里留乱码)
|
||||
{"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库里出现乱码实体名字符",
|
||||
"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := summarizeForEntity(c.in, 40)
|
||||
if got != c.want {
|
||||
t.Errorf("summarizeForEntity(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaTriplesFromText_DeterministicRegardlessOfNLP(t *testing.T) {
|
||||
// 核心回归:媒体入 L3 不再依赖 NLP 提取器的运气。
|
||||
//
|
||||
// 实测 LLM 的 477 字图片描述经提取器只产出「水平 -分割-> 成」,
|
||||
// obj 仅 1 字被 validEntityName 拒掉 → ec=0 rc=0 → 媒体记忆进不了图库,
|
||||
// 且时好时坏取决于描述文本。这里验证确定性路径。
|
||||
longDesc := "这张图片是一张纯色块构成的抽象图像,不包含任何文字、人物、物体或可识别的场景。" +
|
||||
"整体构成:一个小尺寸的正方形图像,被水平分割成三条颜色条带。"
|
||||
text := "媒体内容:\n[image/png 89e293b42546] " + longDesc
|
||||
|
||||
triples := mediaTriplesFromText(text)
|
||||
if len(triples) < 2 {
|
||||
t.Fatalf("应至少产出类型+内容两条三元组,实际 %d", len(triples))
|
||||
}
|
||||
|
||||
// 每条都必须能通过 validEntityName(经 Commit 实证)
|
||||
g, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
sids, ec, rc, err := g.CommitWithMedia(triples, "det", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec == 0 || rc == 0 {
|
||||
t.Fatalf("确定性三元组应能写入图库,实际 ec=%d rc=%d", ec, rc)
|
||||
}
|
||||
if len(sids) == 0 {
|
||||
t.Fatal("应返回句子 id 供 bindSentenceMedia 绑定")
|
||||
}
|
||||
// SentenceText 必须含 digest,否则绑定还是断的
|
||||
for st := range sids {
|
||||
if !contains(st, "89e293b42546") {
|
||||
t.Errorf("句子必须含短 digest 供反解: %q", st)
|
||||
}
|
||||
}
|
||||
|
||||
// 描述为空时仍应产出类型三元组——媒体节点不能因为没描述就不存在
|
||||
bare := mediaTriplesFromText("[image/png 89e293b42546]")
|
||||
if len(bare) != 1 {
|
||||
t.Fatalf("无描述时应只有类型三元组,实际 %d 条", len(bare))
|
||||
}
|
||||
if bare[0].Relation != "类型" {
|
||||
t.Errorf("无描述时那条应是类型三元组: %+v", bare[0])
|
||||
if err := ms.Delete(digest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ms.Stat(digest); err == nil {
|
||||
t.Fatal("删除后内容应已移除")
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ func newInputTestAgent(t *testing.T) (*Agent, *media.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatalf("media.New: %v", err)
|
||||
}
|
||||
@ -192,63 +192,84 @@ func TestResolveInput_UnifiesAllModalities(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 模型工具侧:sentenceWithMediaMarkers ----------
|
||||
// ---------- 模型工具侧:memory_digests 结构化传递 ----------
|
||||
|
||||
// 模型只知道 digest(从对话或 memory_recall 的「关联媒体」读到),
|
||||
// 不该要求它自己按内核格式拼标记——格式写错的后果是引用静默挂不上。
|
||||
func TestSentenceWithMediaMarkers(t *testing.T) {
|
||||
// 模型只知道 digest(从对话或 memory_recall 的「关联媒体」读到)。
|
||||
// 它不再需要自己拼任何标记:digest 作为结构化字段随三元组提交。
|
||||
func TestResolveMediaDigestsAndNoMarkerText(t *testing.T) {
|
||||
a, ms := newInputTestAgent(t)
|
||||
digest, err := ms.Put([]byte("marker-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "一张紫蓝红三色带图",
|
||||
})
|
||||
digest, err := ms.Put([]byte("marker-bytes"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
t.Run("短digest补全并生成标记", func(t *testing.T) {
|
||||
got := a.sentenceWithMediaMarkers("用户发来一张图。", []string{digest[:12]})
|
||||
if !strings.Contains(got, "三色带图") {
|
||||
t.Errorf("描述未并入句子: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, digest[:12]) {
|
||||
t.Errorf("digest 未并入句子(反查会失效): %q", got)
|
||||
}
|
||||
// 反解必须成功,否则 bindSentenceMedia 挂不上引用
|
||||
if got := extractMediaDigests(got); len(got) != 1 {
|
||||
t.Errorf("生成的标记无法被 extractMediaDigests 反解: %v", got)
|
||||
t.Run("短digest补全", func(t *testing.T) {
|
||||
got := a.resolveMediaDigests([]string{digest[:12]})
|
||||
if len(got) != 1 || got[0] != digest {
|
||||
t.Fatalf("短 digest 应补全为完整 digest,得到 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("模型已写标记时不重复追加", func(t *testing.T) {
|
||||
sentence := "看这个 [image/png " + digest[:12] + "] 三色带图"
|
||||
got := a.sentenceWithMediaMarkers(sentence, []string{digest[:12]})
|
||||
if n := strings.Count(got, digest[:12]); n != 1 {
|
||||
t.Errorf("digest 出现 %d 次,期望 1 次: %q", n, got)
|
||||
t.Run("无法解析的digest被丢弃", func(t *testing.T) {
|
||||
if got := a.resolveMediaDigests([]string{"ffffffffffff"}); len(got) != 0 {
|
||||
t.Errorf("不存在的 digest 不该保留: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空句子时标记本身充当句子", func(t *testing.T) {
|
||||
got := a.sentenceWithMediaMarkers("", []string{digest})
|
||||
if got == "" {
|
||||
t.Error("媒体必须有句子落点,否则 media_refs 无从挂起")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无法解析的digest被跳过", func(t *testing.T) {
|
||||
got := a.sentenceWithMediaMarkers("原句。", []string{"ffffffffffff"})
|
||||
if got != "原句。" {
|
||||
t.Errorf("不存在的 digest 不该造出标记: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无媒体存储时原样返回", func(t *testing.T) {
|
||||
t.Run("无媒体存储时返回nil", func(t *testing.T) {
|
||||
bare := &Agent{}
|
||||
if got := bare.sentenceWithMediaMarkers("原句。", []string{digest}); got != "原句。" {
|
||||
t.Errorf("无媒体存储时应原样返回: %q", got)
|
||||
if got := bare.resolveMediaDigests([]string{digest}); got != nil {
|
||||
t.Errorf("无媒体存储时应返回 nil: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 句子文本必须保持原样:媒体归属走结构化块边,不往文本里贴 marker。
|
||||
func TestMemoryCommit_DoesNotPolluteSentenceText(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ms.Close()
|
||||
a := &Agent{memory: g, mediaStore: ms}
|
||||
|
||||
digest, _ := ms.Put([]byte("clean-sentence"), media.Item{MIME: "image/png"})
|
||||
|
||||
sentence := "用户发来一张图。"
|
||||
triples := []memory.Triple{{
|
||||
Subject: "用户", Relation: "发来", Object: "图片",
|
||||
SentenceText: sentence,
|
||||
MediaDigests: a.resolveMediaDigests([]string{digest[:12]}),
|
||||
}}
|
||||
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := a.memory.Recall([]string{"用户"}, nil, 2, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("召回为空")
|
||||
}
|
||||
if res.Relations[0].SentenceText != sentence {
|
||||
t.Errorf("句子文本被污染: %q", res.Relations[0].SentenceText)
|
||||
}
|
||||
blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
|
||||
t.Errorf("块应挂到句子,实际 %+v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- resolveMediaDigests ----------
|
||||
|
||||
func TestResolveMediaDigests(t *testing.T) {
|
||||
@ -275,100 +296,105 @@ func TestResolveMediaDigests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- bindDocMedia ----------
|
||||
// ---------- 文档持有的一等记忆块 ----------
|
||||
|
||||
func TestDocCommit_StoresBlocks(t *testing.T) {
|
||||
// doc_commit 带 media_digests 时,媒体应作为一等块直接存在文档上,
|
||||
// 并随 doc 一起持久化(不再靠 media_refs 保活)。
|
||||
dir := t.TempDir()
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ds.Stop()
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ms.Close()
|
||||
|
||||
func TestBindDocMedia(t *testing.T) {
|
||||
a, ms := newInputTestAgent(t)
|
||||
d1, _ := ms.Put([]byte("doc-one"), media.Item{MIME: "image/png"})
|
||||
d2, _ := ms.Put([]byte("doc-two"), media.Item{MIME: "image/png"})
|
||||
|
||||
if n := a.bindDocMedia("doc_x", []string{d1, d2}); n != 2 {
|
||||
t.Fatalf("绑定 %d 条,期望 2", n)
|
||||
doc := &document.Doc{ID: "doc_x", Summary: "s", Content: "c"}
|
||||
for _, d := range []string{d1, d2} {
|
||||
if b, ok := (&Agent{mediaStore: ms}).blockFromDigest(d); ok {
|
||||
doc.Blocks = append(doc.Blocks, b)
|
||||
}
|
||||
}
|
||||
refs, err := ms.Refs(media.OwnerDocument, "doc_x")
|
||||
if err != nil {
|
||||
t.Fatalf("Refs: %v", err)
|
||||
}
|
||||
if len(refs) != 2 {
|
||||
t.Errorf("引用 = %v,期望 2 条", refs)
|
||||
if err := ds.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n := a.bindDocMedia("", []string{d1}); n != 0 {
|
||||
t.Error("空 docID 不该绑定")
|
||||
blocks := ds.Blocks()
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("文档应持有 2 个块,实际 %d", len(blocks))
|
||||
}
|
||||
bare := &Agent{}
|
||||
if n := bare.bindDocMedia("doc_y", []string{d1}); n != 0 {
|
||||
t.Error("无媒体存储时不该绑定")
|
||||
seen := map[string]bool{}
|
||||
for _, b := range blocks {
|
||||
seen[b.PayloadDigest] = true
|
||||
}
|
||||
if !seen[d1] || !seen[d2] {
|
||||
t.Errorf("块 digest 不对: %+v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- docMediaContext ----------
|
||||
// ---------- 文档持有块标签(doc_query 展示用) ----------
|
||||
|
||||
func TestDocMediaContext(t *testing.T) {
|
||||
func TestBlockLabelsForDoc(t *testing.T) {
|
||||
a, ms := newInputTestAgent(t)
|
||||
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "文档里的配图",
|
||||
})
|
||||
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{MIME: "image/png"})
|
||||
b, ok := a.blockFromDigest(digest)
|
||||
if !ok {
|
||||
t.Fatal("blockFromDigest 失败")
|
||||
}
|
||||
|
||||
t.Run("优先用media_refs", func(t *testing.T) {
|
||||
if err := ms.AddRef(digest, media.OwnerDocument, "doc_refs"); err != nil {
|
||||
t.Fatalf("AddRef: %v", err)
|
||||
t.Run("从文档持有的一等块渲染", func(t *testing.T) {
|
||||
got := a.blockLabelsForDoc(&document.Doc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}})
|
||||
if !strings.Contains(got, shortDigest(digest)) {
|
||||
t.Errorf("标签应含短 digest: %q", got)
|
||||
}
|
||||
got := a.docMediaContext("doc_refs", "正文里没有任何标记")
|
||||
if !strings.Contains(got, "文档里的配图") {
|
||||
t.Errorf("未从 media_refs 取到媒体说明: %q", got)
|
||||
if !strings.Contains(got, "image/png") {
|
||||
t.Errorf("标签应含 MIME: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无引用时回退解析正文标记", func(t *testing.T) {
|
||||
content := "旧正文 [image/png " + digest[:12] + "] 文档里的配图"
|
||||
got := a.docMediaContext("doc_legacy", content)
|
||||
if !strings.Contains(got, "文档里的配图") {
|
||||
t.Errorf("历史文档只有标记时应回退解析: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("既无引用也无标记", func(t *testing.T) {
|
||||
if got := a.docMediaContext("doc_empty", "普通正文"); got != "" {
|
||||
t.Run("无块时为空", func(t *testing.T) {
|
||||
if got := a.blockLabelsForDoc(&document.Doc{ID: "doc_x", Content: "普通正文"}); got != "" {
|
||||
t.Errorf("应返回空串,实际 %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无媒体存储", func(t *testing.T) {
|
||||
bare := &Agent{}
|
||||
if got := bare.docMediaContext("doc_x", "任意"); got != "" {
|
||||
if got := bare.blockLabelsForDoc(&document.Doc{ID: "doc_x"}); got != "" {
|
||||
t.Errorf("无媒体存储时应返回空串,实际 %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- mediaMarkerLine ----------
|
||||
// ---------- mediaLabel ----------
|
||||
|
||||
// 标记格式的唯一生成处。此前 mediaSummaryForEvent 与 mediaContextForSentences
|
||||
// 各拼一份,改动截断长度或分隔符时只改一处,另一处写出的标记就再也解析不回来。
|
||||
func TestMediaMarkerLine(t *testing.T) {
|
||||
// 媒体标签的唯一生成处:只含 MIME 与短 digest,不含任何生成的描述。
|
||||
func TestMediaLabel(t *testing.T) {
|
||||
a, ms := newInputTestAgent(t)
|
||||
_ = a
|
||||
|
||||
described, _ := ms.Put([]byte("with-desc"), media.Item{
|
||||
MIME: "image/png", Description: "已描述的图",
|
||||
})
|
||||
if got := a.mediaMarkerLine(described); !strings.Contains(got, "已描述的图") {
|
||||
t.Errorf("有描述时应带描述: %q", got)
|
||||
digest, _ := ms.Put([]byte("labelled"), media.Item{MIME: "image/png"})
|
||||
it, err := ms.Stat(digest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 「已入库但还没描述」与「压根没有媒体」必须可区分
|
||||
bare, _ := ms.Put([]byte("no-desc"), media.Item{MIME: "image/png"})
|
||||
got := a.mediaMarkerLine(bare)
|
||||
if !strings.Contains(got, "(未描述)") {
|
||||
t.Errorf("无描述时应有占位符: %q", got)
|
||||
got := mediaLabel(it)
|
||||
if !strings.Contains(got, "image/png") {
|
||||
t.Errorf("标签应含 MIME: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, shortDigest(bare)) {
|
||||
if !strings.Contains(got, shortDigest(digest)) {
|
||||
t.Errorf("必须带短 digest 供反查: %q", got)
|
||||
}
|
||||
|
||||
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出指向虚无的标记
|
||||
if got := a.mediaMarkerLine("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); got != "" {
|
||||
t.Errorf("查不到的 digest 应返回空串,实际 %q", got)
|
||||
if got := mediaLabel(nil); got != "" {
|
||||
t.Errorf("nil 应返回空串,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -384,13 +410,13 @@ func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
|
||||
}
|
||||
t.Cleanup(func() { g.Close() })
|
||||
|
||||
ds := document.NewStore(filepath.Join(dir, "documents"))
|
||||
ds := document.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatalf("doc store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ds.Stop() })
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatalf("media.New: %v", err)
|
||||
}
|
||||
@ -410,9 +436,7 @@ func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
|
||||
// memory_commit 带 media_digests:三元组入库后必须能从句子反查回那份字节。
|
||||
func TestToolMemoryCommit_BindsMedia(t *testing.T) {
|
||||
a, ms := newToolTestAgent(t)
|
||||
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "提交时关联的图",
|
||||
})
|
||||
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{MIME: "image/png"})
|
||||
|
||||
out := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
Name: "memory_commit",
|
||||
@ -438,9 +462,12 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
|
||||
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
|
||||
t.Fatal("没有句子落点 —— 媒体引用无从挂起")
|
||||
}
|
||||
refs, _ := ms.Refs(media.OwnerGraphSentence, strconv.FormatInt(res.Relations[0].SentenceID, 10))
|
||||
if len(refs) != 1 || refs[0] != digest {
|
||||
t.Errorf("句子引用 = %v,期望 [%s]", refs, digest)
|
||||
blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
|
||||
if err != nil {
|
||||
t.Fatalf("BlocksForNode: %v", err)
|
||||
}
|
||||
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
|
||||
t.Errorf("句子块 = %+v,期望 [%s]", blocks, digest)
|
||||
}
|
||||
}
|
||||
|
||||
@ -488,12 +515,10 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// doc_commit 带 media_digests:标记进正文(否则检索不到)+ 引用挂文档 owner(否则 GC 会清)。
|
||||
// doc_commit 带 media_digests:媒体成为文档直接持有的一等块;正文保持原样。
|
||||
func TestToolDocCommit_BindsMedia(t *testing.T) {
|
||||
a, ms := newToolTestAgent(t)
|
||||
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "笔记里的插图",
|
||||
})
|
||||
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{MIME: "image/png"})
|
||||
|
||||
out := a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_commit",
|
||||
@ -512,21 +537,24 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
|
||||
t.Fatal("文档未写入")
|
||||
}
|
||||
d := docs[0]
|
||||
if !strings.Contains(d.Content, "笔记里的插图") {
|
||||
t.Errorf("标记未进正文(向量索引看不到这份媒体): %q", d.Content)
|
||||
if strings.Contains(d.Content, "image/png") {
|
||||
t.Errorf("正文不该被媒体标记污染: %q", d.Content)
|
||||
}
|
||||
refs, _ := ms.Refs(media.OwnerDocument, d.ID)
|
||||
if len(refs) != 1 || refs[0] != digest {
|
||||
t.Errorf("文档引用 = %v,期望 [%s]", refs, digest)
|
||||
var held bool
|
||||
for _, b := range d.Blocks {
|
||||
if b.PayloadDigest == digest {
|
||||
held = true
|
||||
}
|
||||
}
|
||||
if !held {
|
||||
t.Errorf("文档应持有一等记忆块 [%s],实际 %+v", digest, d.Blocks)
|
||||
}
|
||||
}
|
||||
|
||||
// doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
|
||||
func TestToolDocQuery_ShowsMedia(t *testing.T) {
|
||||
a, ms := newToolTestAgent(t)
|
||||
digest, _ := ms.Put([]byte("query-bytes"), media.Item{
|
||||
MIME: "image/png", Description: "检索命中的配图",
|
||||
})
|
||||
digest, _ := ms.Put([]byte("query-bytes"), media.Item{MIME: "image/png"})
|
||||
|
||||
a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_commit",
|
||||
@ -545,7 +573,7 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) {
|
||||
// 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。
|
||||
var found bool
|
||||
for _, e := range a.context.Recent(10) {
|
||||
if strings.Contains(e.Response, "检索命中的配图") {
|
||||
if strings.Contains(e.Response, shortDigest(digest)) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,14 +6,13 @@
|
||||
// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走:
|
||||
//
|
||||
// processMediaInput → captureBlockMedia(入 CAS)
|
||||
// → Prune → transferMediaRefs(L0→L2 引用转移)
|
||||
// → Prune(L0→L2 块迁移)
|
||||
// → describePendingMedia(真实视觉模型生成描述)
|
||||
// → archiveColdDocs → commitTriplesWithMedia → bindSentenceMedia(L2→L3)
|
||||
// → archiveColdDocs → commitTriplesWithMedia → bindSentenceBlocks(L2→L3)
|
||||
// → 第二轮提问,验证 agent 真能召回
|
||||
//
|
||||
// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**。
|
||||
// 本文件的直接动机是一个真实缺陷——core.New() 漏了 rc.SetMediaStore(cfg.MediaStore),
|
||||
// 于是 L0→L2 引用转移在生产里永远静默 return,而手工注入 store 的单测全绿。
|
||||
// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**——
|
||||
// 手工注入 store 的单测全绿而生产链路断开,是本文件要拦的典型缺陷。
|
||||
//
|
||||
// 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑:
|
||||
//
|
||||
@ -172,7 +171,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||||
t.Fatalf("set default provider: %v", err)
|
||||
}
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 256<<20)
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatalf("media store: %v", err)
|
||||
}
|
||||
@ -184,7 +183,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||||
}
|
||||
t.Cleanup(func() { graph.Close() })
|
||||
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"))
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
t.Fatalf("doc store: %v", err)
|
||||
}
|
||||
@ -201,8 +200,6 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||||
Memory: graph,
|
||||
DocStore: docStore,
|
||||
MediaStore: ms,
|
||||
MediaGCInterval: 0, // 本测试自己控制 GC 时机
|
||||
MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia
|
||||
StageHost: NewStageHost(),
|
||||
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
|
||||
InputProcessing: types.InputProcessingConfig{},
|
||||
@ -252,97 +249,71 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
a.handleInput(evt)
|
||||
t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds())
|
||||
|
||||
// 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带
|
||||
// `COALESCE(description,'') != ''`,只返回**已描述**的媒体,
|
||||
// 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。
|
||||
items, err := env.mediaSt.Pending(10)
|
||||
// 媒体不再有文字描述:CAS 里只有字节、元数据与向量。
|
||||
// 这里直接按 digest 定位刚落的图(不再有 Pending 队列)。
|
||||
st := env.mediaSt.Stats()
|
||||
if st["count"].(int) != 1 {
|
||||
t.Fatalf("CAS 应自动收到 1 张图,实际 %v 张(captureBlockMedia 未被触发?)", st["count"])
|
||||
}
|
||||
var digest string
|
||||
var found bool
|
||||
for _, e := range a.context.Recent(0) {
|
||||
for _, b := range e.Blocks {
|
||||
digest, found = b.PayloadDigest, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("无法从上下文块定位刚落盘的图")
|
||||
}
|
||||
it0, err := env.mediaSt.Stat(digest)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("CAS 应自动收到 1 张图,实际 %d 张(captureBlockMedia 未被触发?)", len(items))
|
||||
}
|
||||
digest := items[0].Digest
|
||||
t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s",
|
||||
digest[:12], items[0].Size, items[0].Tool)
|
||||
digest[:12], it0.Size, it0.Tool)
|
||||
|
||||
stored, err := env.mediaSt.Get(digest)
|
||||
if err != nil || !bytes.Equal(stored, img) {
|
||||
t.Fatalf("落盘内容与原图不一致 (err=%v)", err)
|
||||
}
|
||||
|
||||
// ── 阶段 2:引用自动挂到 ContextEvent 上 ──
|
||||
// ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ──
|
||||
//
|
||||
// 这一步验证 bindEventMedia:事件必须拿到 ID 且 media_refs 里
|
||||
// 有对应 context owner 记录。两者只写一个的后果是 GC 误删或永不清理。
|
||||
// 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块;
|
||||
// 事件文本必须保持原样(不再往正文里贴媒体标记)。
|
||||
var evtID string
|
||||
var summaryOK bool
|
||||
for _, e := range a.context.Recent(0) {
|
||||
if len(e.Media) > 0 {
|
||||
if len(e.Blocks) > 0 {
|
||||
evtID = e.ID
|
||||
summaryOK = strings.Contains(e.Input, digest[:12])
|
||||
if strings.Contains(e.Input, digest[:12]) {
|
||||
t.Error("事件 Input 里被写入了媒体标记——描述式索引链应该已经拆除")
|
||||
}
|
||||
if e.Blocks[0].PayloadDigest != digest {
|
||||
t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if evtID == "" {
|
||||
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)")
|
||||
}
|
||||
ctxRefs, err := env.mediaSt.Refs(media.OwnerContext, evtID)
|
||||
if err != nil || len(ctxRefs) != 1 || ctxRefs[0] != digest {
|
||||
t.Fatalf("context owner 引用缺失: refs=%v err=%v", ctxRefs, err)
|
||||
}
|
||||
if !summaryOK {
|
||||
t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" +
|
||||
"L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉")
|
||||
}
|
||||
t.Logf("✓ 阶段2 引用自动绑定: event=%s owner=context 摘要内嵌=%v", evtID, summaryOK)
|
||||
t.Logf("✓ 阶段2 块自动绑定: event=%s", evtID)
|
||||
|
||||
// ── 阶段 3:描述由后台循环自动生成(真实视觉模型)──
|
||||
pending, err := env.mediaSt.Pending(5)
|
||||
if err != nil {
|
||||
// ── 阶段 3:媒体只按自己的向量被索引,不再生成任何描述 ──
|
||||
if it, err := env.mediaSt.Stat(digest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending))
|
||||
}
|
||||
|
||||
t1 := time.Now()
|
||||
a.describePendingMedia()
|
||||
t.Logf("描述生成耗时 %.1fs", time.Since(t1).Seconds())
|
||||
|
||||
it, err := env.mediaSt.Stat(digest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.Description == "" {
|
||||
t.Fatal("描述为空——describePendingMedia 未能通过视觉源生成描述")
|
||||
}
|
||||
sawColors := strings.Contains(it.Description, "紫") &&
|
||||
strings.Contains(it.Description, "蓝") &&
|
||||
strings.Contains(it.Description, "红")
|
||||
t.Logf("✓ 阶段3 描述自动生成 (%d 字, 源=%s): %s",
|
||||
len([]rune(it.Description)), it.DescribedBy, truncRunes(it.Description, 90))
|
||||
if !sawColors {
|
||||
t.Errorf("描述未含紫/蓝/红三色,视觉模型可能没真正看到图片: %s",
|
||||
truncRunes(it.Description, 200))
|
||||
}
|
||||
if left, _ := env.mediaSt.Pending(5); len(left) != 0 {
|
||||
t.Errorf("描述完成后仍在待描述队列(%d 条)——会被反复重描述", len(left))
|
||||
}
|
||||
// 有描述之后 Search 才应能命中(它按 description 做 LIKE)
|
||||
if found, err := env.mediaSt.Search("紫", media.KindImage, 5); err != nil {
|
||||
t.Errorf("search: %v", err)
|
||||
} else if len(found) == 0 {
|
||||
t.Error("描述已生成但 Search(\"紫\") 命中 0 条——媒体库关键词入口失效")
|
||||
} else if len(it.Vec) == 0 {
|
||||
// 未配置多模态空间时就没有向量——这是合法的降级状态,
|
||||
// 但要明确报出来,而不是靠描述文本假装能检索。
|
||||
t.Log("未配置多模态空间:本图无向量,之后只能靠块结构召回 digest")
|
||||
} else {
|
||||
t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found))
|
||||
t.Logf("✓ 阶段3 已写入原生向量: dim=%d", len(it.Vec))
|
||||
}
|
||||
|
||||
// ── 阶段 4:Prune 自动把引用从 L0 转移到 L2 ──
|
||||
// ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ──
|
||||
//
|
||||
// MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。
|
||||
// 这一步专门守 core.New() 里 rc.SetMediaStore 的接线:漏了它
|
||||
// transferMediaRefs 直接 return,引用永久悬空在 context owner 上。
|
||||
// 迁移的是块本身(同一身份换层);L0 中不该再留下它。
|
||||
// 填充数量必须 > Prune 内部固定的 10 条保护窗口。
|
||||
//
|
||||
// Prune 无条件保护最后 10 条事件(protected := events[len-10:]),
|
||||
@ -366,33 +337,35 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
|
||||
docRefsFound := ""
|
||||
for _, d := range env.docStore.RecentDocs(20) {
|
||||
refs, err := env.mediaSt.Refs(media.OwnerDocument, d.ID)
|
||||
if err == nil && len(refs) > 0 && refs[0] == digest {
|
||||
docRefsFound = d.ID
|
||||
break
|
||||
for _, b := range d.Blocks {
|
||||
if b.PayloadDigest == digest {
|
||||
docRefsFound = d.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if docRefsFound == "" {
|
||||
t.Fatal("引用未转移到 document owner——" +
|
||||
"core.New() 是否漏了 rc.SetMediaStore(cfg.MediaStore)?" +
|
||||
"(该缺陷曾真实存在:手工注入 store 的单测全绿,生产里永远静默 return)")
|
||||
t.Fatal("块未随归档事件迁移到 L2 文档")
|
||||
}
|
||||
if left, _ := env.mediaSt.Refs(media.OwnerContext, evtID); len(left) != 0 {
|
||||
t.Errorf("旧的 context 引用未注销(%d 条),引用计数永不归零 → blob 永不回收", len(left))
|
||||
// 同一块不能同时留在 L0。
|
||||
for _, e := range a.context.Recent(0) {
|
||||
for _, b := range e.Blocks {
|
||||
if b.PayloadDigest == digest {
|
||||
t.Errorf("块仍留在 L0(evt %s),违反单层不变量", e.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 阶段4 引用自动转移: context/%s → document/%s", evtID, docRefsFound)
|
||||
t.Logf("✓ 阶段4 块自动迁移: context/%s → document/%s", evtID, docRefsFound)
|
||||
|
||||
// 转移全程内容必须可读:先挂后销的顺序若反了,
|
||||
// 计数会瞬时归零,并发 GC 会把仍被引用的内容当孤儿删掉。
|
||||
// 迁移全程内容必须可读:块虽换了层,字节仍在。
|
||||
if _, err := env.mediaSt.Get(digest); err != nil {
|
||||
t.Fatalf("转移后内容不可读: %v", err)
|
||||
t.Fatalf("迁移后内容不可读: %v", err)
|
||||
}
|
||||
|
||||
// ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ──
|
||||
// ── 阶段 5:archiveColdDocs 自动把块连到 L3 文档节点 ──
|
||||
//
|
||||
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
|
||||
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
|
||||
// 而不是替代它(Commit/bindSentenceMedia/releaseDocMedia 全部由它自己调)。
|
||||
// 而不是替代它(commitTriplesWithMedia/linkBlocksToDocument 全由它自己调)。
|
||||
for _, d := range env.docStore.RecentDocs(20) {
|
||||
if d.ID == docRefsFound {
|
||||
d.LastAccess = time.Now().Add(-100 * time.Hour)
|
||||
@ -401,50 +374,61 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
}
|
||||
a.archiveColdDocs()
|
||||
|
||||
// 块可能以 document --contains--> block(文档归档)或
|
||||
// sentence --contains--> block(对话三元组)两种边存在。
|
||||
sentRefs := 0
|
||||
var boundSentence int64
|
||||
docBound := 0
|
||||
rows, err := env.graph.Recall(nil, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatalf("graph recall: %v", err)
|
||||
}
|
||||
t.Logf("图库实体数 %d", len(rows.Entities))
|
||||
docBlocks, err := env.graph.BlocksForNode("document", docRefsFound)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docBound = len(docBlocks)
|
||||
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
|
||||
for sid := int64(1); sid <= 40; sid++ {
|
||||
refs, err := env.mediaSt.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
||||
if err == nil && len(refs) > 0 {
|
||||
sentRefs += len(refs)
|
||||
blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||
if err == nil && len(blocks) > 0 {
|
||||
sentRefs += len(blocks)
|
||||
if boundSentence == 0 {
|
||||
boundSentence = sid
|
||||
}
|
||||
}
|
||||
}
|
||||
if sentRefs == 0 {
|
||||
t.Error("L2→L3 未绑定任何 graph_sentence 引用——" +
|
||||
"bindSentenceMedia 未被 commitTriplesWithMedia 触发," +
|
||||
"或句子正文里没有可反解的短 digest")
|
||||
if sentRefs == 0 && docBound == 0 {
|
||||
t.Error("L2→L3 未写入任何块边——linkBlocksToDocument 未被 archiveColdDocs 触发")
|
||||
} else if docBound > 0 {
|
||||
t.Logf("✓ 阶段5 L3 自动写入: 文档 %s 持有 %d 个块", docRefsFound, docBound)
|
||||
got := docBlocks
|
||||
if got[0].PayloadDigest != digest {
|
||||
t.Errorf("文档节点持有的块 digest 不对: %+v", got)
|
||||
} else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) {
|
||||
t.Errorf("从文档块取回的字节与原图不一致 (err=%v)", err)
|
||||
} else {
|
||||
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
|
||||
}
|
||||
} else {
|
||||
t.Logf("✓ 阶段5 L3 自动绑定: %d 个句子引用,首个 sentences.id=%d", sentRefs, boundSentence)
|
||||
t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence)
|
||||
|
||||
got, err := env.agent.RecallMediaForSentence(boundSentence)
|
||||
if err != nil || len(got) == 0 || got[0] != digest {
|
||||
t.Errorf("从句子反查 digest 失败: got=%v err=%v", got, err)
|
||||
} else if raw, err := env.mediaSt.Get(got[0]); err != nil || !bytes.Equal(raw, img) {
|
||||
got, err := env.agent.RecallBlocksForSentence(boundSentence)
|
||||
if err != nil || len(got) == 0 || got[0].PayloadDigest != digest {
|
||||
t.Errorf("从句子反查块失败: got=%+v err=%v", got, err)
|
||||
} else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) {
|
||||
t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err)
|
||||
} else {
|
||||
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 阶段 6:GC 不能删掉仍被记忆引用的内容 ──
|
||||
removed, freed, err := env.mediaSt.GC(0) // minAge=0,最激进
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ── 阶段 6:内容随块存在,不被单独清理 ──
|
||||
if _, err := env.mediaSt.Stat(digest); err != nil {
|
||||
t.Fatalf("被记忆引用的内容被 GC 删除了(清 %d 条/%d 字节)——"+
|
||||
"引用计数或 owner 语义有误", removed, freed)
|
||||
t.Fatalf("被记忆块持有的内容不存在了: %v", err)
|
||||
}
|
||||
t.Logf("✓ 阶段6 GC(minAge=0) 清 %d 条,被引用内容仍在", removed)
|
||||
t.Logf("✓ 阶段6 被持有内容仍在")
|
||||
|
||||
// ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ──
|
||||
//
|
||||
@ -456,15 +440,10 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
if err := a.indexer.Sync(); err != nil {
|
||||
t.Fatalf("indexer sync: %v", err)
|
||||
}
|
||||
if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" {
|
||||
if mc := a.buildMemoryContext("测试图片", 0); mc != "" {
|
||||
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
|
||||
if strings.Contains(mc, "【关联媒体】") {
|
||||
t.Logf("✓ 记忆上下文含媒体段")
|
||||
} else {
|
||||
t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效")
|
||||
}
|
||||
} else {
|
||||
t.Error("图库召回为空,agent 无从得知历史媒体")
|
||||
t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)")
|
||||
}
|
||||
|
||||
ask := &agentIO.InputEvent{
|
||||
@ -483,6 +462,9 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
a.handleInput(ask)
|
||||
t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds())
|
||||
|
||||
// 第二轮仍走真实 LLM:这里只验证链路不报错、有回复。
|
||||
// 不再断言"答出紫/蓝/红":图片的颜色信息只在原生向量里,
|
||||
// 未配置多模态空间时模型本来就无从得知——那不属于记忆接线缺陷。
|
||||
var answer string
|
||||
select {
|
||||
case out := <-respCh:
|
||||
@ -491,27 +473,20 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
t.Fatal("第二轮没有收到回复")
|
||||
}
|
||||
t.Logf("agent 回答: %s", truncRunes(answer, 220))
|
||||
|
||||
recalled := strings.Contains(answer, "紫") &&
|
||||
strings.Contains(answer, "蓝") &&
|
||||
strings.Contains(answer, "红")
|
||||
if !recalled {
|
||||
t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+
|
||||
"也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s",
|
||||
truncRunes(answer, 300))
|
||||
} else {
|
||||
t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红")
|
||||
if strings.HasPrefix(answer, "处理错误:") {
|
||||
t.Skipf("上游 LLM 调用失败,端到端召回无法判定: %s", truncRunes(answer, 160))
|
||||
}
|
||||
t.Logf("✓ 阶段7 E2E 链路贯通(召回能力取决于是否配置多模态向量空间)")
|
||||
|
||||
st := env.mediaSt.Stats()
|
||||
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v / 无引用 %v",
|
||||
st["count"], st["total_bytes"], st["described"], st["unreferenced"])
|
||||
st = env.mediaSt.Stats()
|
||||
t.Logf("收尾: %v 条 / %v 字节 / 类型 %v",
|
||||
st["count"], st["total_bytes"], st["by_kind"])
|
||||
}
|
||||
|
||||
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
|
||||
//
|
||||
// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色,
|
||||
// 无法区分真召回与先验偏好。
|
||||
// 没有这条对照,任何"答出了具体内容"的结果都可能只是模型先验,
|
||||
// 无法区分真召回与猜测。
|
||||
func TestMediaLive_NegativeControl(t *testing.T) {
|
||||
c := requireLiveCfg(t)
|
||||
env := newLiveEnv(t, c)
|
||||
|
||||
@ -1,183 +1,199 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
// 媒体记忆的两条后台循环。
|
||||
// 媒体与记忆块的生命周期辅助。
|
||||
//
|
||||
// mediaGCLoop 清理无人引用的 blob,让容量上限真正生效。
|
||||
// mediaDescribeLoop 给未描述的媒体生成文字描述(方案 C 的另一半)。
|
||||
// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
|
||||
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除。
|
||||
// 图片不靠文本描述索引——它只按自己的统一空间向量被检索。
|
||||
|
||||
// heldMediaDigests 汇总三层记忆当前持有的媒体 digest 集合。
|
||||
//
|
||||
// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s
|
||||
// (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒,
|
||||
// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话——
|
||||
// 这一轮模型本来就直接看着图。
|
||||
|
||||
const (
|
||||
// mediaDescribeBatch 是单轮描述的媒体条数上限。
|
||||
//
|
||||
// 取 4:既有回退链的 modalFallbackMaxBlocks 是 6(一次请求最多带 6 个媒体),
|
||||
// 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字
|
||||
// 无法可靠切分回各自的 digest。
|
||||
mediaDescribeBatch = 4
|
||||
|
||||
// mediaDescribeMinInterval 是两轮描述之间的最小间隔。
|
||||
//
|
||||
// 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它
|
||||
// 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。
|
||||
mediaDescribeMinInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
// mediaGCLoop 周期清理无引用的媒体内容。
|
||||
//
|
||||
// 不做这件事的后果:容量上限形同虚设。CAS 的 GC 只在被显式调用时执行,
|
||||
// 而 Put 路径不触发它——一次 see_video 抽 10 帧,帧本身没人引用(工具
|
||||
// 结果被 Prune 掉之后),若无人清理就会一直堆在磁盘上。
|
||||
func (a *Agent) mediaGCLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] mediaGCLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||
time.Sleep(time.Second)
|
||||
go a.mediaGCLoop()
|
||||
// CAS 是全库字节存储,它的检索结果不等于「记忆里的媒体」——
|
||||
// 召回前用它把已无处可归的内容过滤掉。
|
||||
func (a *Agent) heldMediaDigests() map[string]bool {
|
||||
held := map[string]bool{}
|
||||
collect := func(blocks []memory.MemoryBlock) {
|
||||
for _, b := range blocks {
|
||||
if b.PayloadDigest != "" {
|
||||
held[b.PayloadDigest] = true
|
||||
}
|
||||
}
|
||||
}()
|
||||
if a.mediaStore == nil || a.mediaGCInterval <= 0 {
|
||||
}
|
||||
if a.context != nil {
|
||||
collect(a.context.Blocks())
|
||||
}
|
||||
if a.docStore != nil {
|
||||
collect(a.docStore.Blocks())
|
||||
}
|
||||
if a.memory != nil {
|
||||
if blocks, err := a.memory.MemoryBlocks(); err == nil {
|
||||
collect(blocks)
|
||||
}
|
||||
}
|
||||
return held
|
||||
}
|
||||
|
||||
// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。
|
||||
// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。
|
||||
func (a *Agent) payloadHeld(digest string) bool {
|
||||
if digest == "" {
|
||||
return false
|
||||
}
|
||||
if a.context != nil {
|
||||
for _, b := range a.context.Blocks() {
|
||||
if b.PayloadDigest == digest {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.docStore != nil {
|
||||
for _, b := range a.docStore.Blocks() {
|
||||
if b.PayloadDigest == digest {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.memory != nil {
|
||||
if blocks, err := a.memory.MemoryBlocks(); err == nil {
|
||||
for _, b := range blocks {
|
||||
if b.PayloadDigest == digest {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// forgetPayloads 在记忆块被永久删除后删除它们的内容。
|
||||
//
|
||||
// 与文本块一致:删除块即删除内容。只有确认没有任何存活块仍共享该 digest
|
||||
// 时才删字节(同一张图可能被多个块引用)。
|
||||
func (a *Agent) forgetPayloads(digests []string) {
|
||||
if a.mediaStore == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(a.mediaGCInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
removed, freed, err := a.mediaStore.GC(a.mediaGCMinAge)
|
||||
if err != nil {
|
||||
log.Printf("[media] GC 失败: %v", err)
|
||||
continue
|
||||
}
|
||||
if removed > 0 {
|
||||
st := a.mediaStore.Stats()
|
||||
log.Printf("[media] GC 清理 %d 条(释放 %d 字节),剩余 %v 条 / %v 字节",
|
||||
removed, freed, st["count"], st["total_bytes"])
|
||||
}
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
for _, d := range digests {
|
||||
if d == "" || a.payloadHeld(d) {
|
||||
continue
|
||||
}
|
||||
if err := a.mediaStore.Delete(d); err != nil {
|
||||
log.Printf("[media] 删除内容失败 %s: %v", shortDigest(d), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mediaDescribeLoop 给未描述的媒体补文字描述。
|
||||
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。
|
||||
//
|
||||
// 描述文本才是持久语义记忆:blob 会被容量 GC 淘汰,而描述留在 media 表里,
|
||||
// 并经 mediaSummaryForEvent 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。
|
||||
// 于是「那张紫蓝红三色带图」在原始字节早已被清掉之后仍然可被检索到。
|
||||
func (a *Agent) mediaDescribeLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] mediaDescribeLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||
time.Sleep(time.Second)
|
||||
go a.mediaDescribeLoop()
|
||||
}
|
||||
}()
|
||||
if a.mediaStore == nil || !a.mediaDescribe {
|
||||
// 触发场景(任一变化都会导致旧向量无法参与查询):
|
||||
// - 切换模型(模型 A→模型 B,fp 变了)
|
||||
// - 切换向量维度(ONNX→HTTP dim 512→1024)
|
||||
// - 首次部署嵌入服务(历史无向量的媒体补算)
|
||||
// - 嵌入服务离线后重新上线(失败条目 vec_model 仍为空)
|
||||
//
|
||||
// 并发策略:启动时用 worker pool 并行迁移,避免上千张图片串行耗时过长。
|
||||
// 并发数在 ONNX 内嵌路径下不超 CPU 核心数(避免 ONNX 并发限流),
|
||||
// 外部 API 路径下不超 8(避免打爆外部服务)。
|
||||
func (a *Agent) reembedStaleMedia() {
|
||||
if a.multimodalSpace == nil || a.mediaStore == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(mediaDescribeMinInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
a.describePendingMedia()
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// describePendingMedia 取一批未描述的媒体逐条描述。
|
||||
//
|
||||
// 逐条而非批量:批量拿回来是一整段文字,无法可靠切分回各自的 digest
|
||||
// (模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次往返
|
||||
// 也要保证「描述 ↔ digest」的对应关系是确定的。
|
||||
func (a *Agent) describePendingMedia() {
|
||||
pending, err := a.mediaStore.Pending(mediaDescribeBatch)
|
||||
fp := a.multimodalSpace.Fingerprint()
|
||||
digests, err := a.mediaStore.StaleVecDigestsAll(fp)
|
||||
if err != nil {
|
||||
log.Printf("[media] 取待描述项失败: %v", err)
|
||||
log.Printf("[media] 查询需重算向量的媒体失败: %v", err)
|
||||
return
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
if len(digests) == 0 {
|
||||
log.Printf("[media] 无需迁移向量(所有媒体已与当前空间对齐 fp=%s)", shortFP(fp))
|
||||
return
|
||||
}
|
||||
|
||||
for _, it := range pending {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
kind := "image"
|
||||
if it.Kind == media.KindAudio {
|
||||
kind = "audio"
|
||||
} else if it.Kind != media.KindImage {
|
||||
// 视频帧以 image 入库;其余大类没有可用的描述通道,
|
||||
// 标记成"不可描述"以免每轮都被 Pending 取出来重试。
|
||||
if err := a.mediaStore.Describe(it.Digest, "", "unsupported"); err != nil {
|
||||
log.Printf("[media] 标记不可描述失败 %s: %v", shortDigest(it.Digest), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
p, srcName := a.resolveModalFallback(kind)
|
||||
if p == nil {
|
||||
// 没有声明该模态能力的源——这一轮整体跳过,不逐条重试。
|
||||
// 配置好之后自然会被下一轮捡起来。
|
||||
log.Printf("[media] 无可用的 %s 描述源,跳过本轮(%d 条待描述)", kind, len(pending))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := a.mediaStore.Get(it.Digest)
|
||||
if err != nil {
|
||||
// blob 已被 GC 清掉但元数据还在(GC 会同删,此处属异常路径):
|
||||
// 标记一下避免死循环。
|
||||
log.Printf("[media] 读内容失败 %s: %v", shortDigest(it.Digest), err)
|
||||
if e := a.mediaStore.Describe(it.Digest, "", "content-missing"); e != nil {
|
||||
log.Printf("[media] 标记内容缺失失败 %s: %v", shortDigest(it.Digest), e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
mime := it.MIME
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
url := media.DataURL(mime, data)
|
||||
|
||||
desc, err := a.chatModalFallbackBatch(p, kind, []string{url}, []string{"high"})
|
||||
if err != nil {
|
||||
// 失败不标记:可能是网络抖动或配额,下一轮该重试。
|
||||
log.Printf("[media] 描述失败 %s (源=%s): %v", shortDigest(it.Digest), srcName, err)
|
||||
continue
|
||||
}
|
||||
if desc == "" {
|
||||
// 空回复通常意味着上游把媒体剥离了——与 modalfallback 里的判断
|
||||
// 同一个道理,视作失败而非"没什么可说的"。
|
||||
log.Printf("[media] 描述为空 %s (源=%s),视作失败", shortDigest(it.Digest), srcName)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := a.mediaStore.Describe(it.Digest, desc, srcName); err != nil {
|
||||
log.Printf("[media] 写描述失败 %s: %v", shortDigest(it.Digest), err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[media] 已描述 %s (%s, %d 字, 源=%s)", shortDigest(it.Digest), kind, len([]rune(desc)), srcName)
|
||||
// 并发度:ONNX 内嵌不超过 4,外部 API 不超过 8(由配置或实际环境动态定)
|
||||
workers := 4
|
||||
if fp[:min(4, len(fp))] == "http:" {
|
||||
workers = 8
|
||||
}
|
||||
log.Printf("[media] 启动向量迁移: %d 条 → 新空间 fp=%s dim=%d workers=%d",
|
||||
len(digests), shortFP(fp), a.multimodalSpace.Dim(), workers)
|
||||
|
||||
jobs := make(chan string, workers*2)
|
||||
var done, failed, unsupported int64
|
||||
var failedMu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for d := range jobs {
|
||||
switch err := a.reembedOne(d, fp); {
|
||||
case err == nil:
|
||||
atomic.AddInt64(&done, 1)
|
||||
case errors.Is(err, vector.ErrModalityUnsupported):
|
||||
// 该模态不在本空间内(如音频):不重试、不计失败,
|
||||
// 也不拿另一个模型的向量顶替。
|
||||
atomic.AddInt64(&unsupported, 1)
|
||||
default:
|
||||
failedMu.Lock()
|
||||
failed++
|
||||
failedMu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i, d := range digests {
|
||||
jobs <- d
|
||||
// 每迁移 20 条输出进度日志,让用户看到迁移在推进
|
||||
if (i+1)%20 == 0 {
|
||||
log.Printf("[media] 向量迁移进度: %d/%d (done=%d failed=%d)", i+1, len(digests), atomic.LoadInt64(&done), failed)
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 不在本空间=%d 总计=%d fp=%s",
|
||||
done, failed, unsupported, len(digests), shortFP(fp))
|
||||
}
|
||||
|
||||
// reembedOne 为单条媒体重新计算向量并写入(stat/get 失败时跳过该条目)。
|
||||
//
|
||||
// 模态不在本空间覆盖范围时返回 ErrModalityUnsupported,调用方据此区分
|
||||
// 「永久无向量」与「本次失败重试」。
|
||||
func (a *Agent) reembedOne(digest, fp string) error {
|
||||
it, err := a.mediaStore.Stat(digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := a.mediaStore.Get(digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mime := it.MIME
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.mediaStore.SetVec(digest, vec, fp)
|
||||
}
|
||||
|
||||
// shortFP 截断 fingerprint 为可读日志格式。
|
||||
func shortFP(fp string) string {
|
||||
if len(fp) > 12 {
|
||||
return fp[:12]
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
@ -2,175 +2,186 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
// 媒体后台循环测试。
|
||||
// 媒体与记忆块的生命周期测试。
|
||||
//
|
||||
// 两条循环都要能在「未启用」时干净退出——它们随 Agent.Start() 无条件启动,
|
||||
// 若不早退就会在每个没配媒体存储的部署上空转一个 goroutine。
|
||||
// 媒体没有独立生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
|
||||
// 块的创建/迁移/删除由记忆系统决定。图片也不靠文本描述索引。
|
||||
|
||||
func newMediaLoopAgent(t *testing.T, gcInterval, minAge time.Duration, describe bool) (*Agent, *media.Store) {
|
||||
func newMediaLoopAgent(t *testing.T) (*Agent, *media.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatalf("media.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ms.Close() })
|
||||
|
||||
a := &Agent{
|
||||
mediaStore: ms,
|
||||
mediaGCInterval: gcInterval,
|
||||
mediaGCMinAge: minAge,
|
||||
mediaDescribe: describe,
|
||||
}
|
||||
a := &Agent{mediaStore: ms}
|
||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||
t.Cleanup(a.cancel)
|
||||
return a, ms
|
||||
}
|
||||
|
||||
func TestMediaGCLoop_ExitsWhenDisabled(t *testing.T) {
|
||||
// 两种禁用形态都必须立刻返回,不留空转 goroutine:
|
||||
// 1. mediaStore 为 nil(媒体记忆整体关闭)
|
||||
// 2. gcInterval 为 0(显式不自动清理)
|
||||
cases := []struct {
|
||||
name string
|
||||
agent *Agent
|
||||
}{
|
||||
{"nil store", func() *Agent {
|
||||
a := &Agent{mediaGCInterval: time.Hour}
|
||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||
return a
|
||||
}()},
|
||||
{"zero interval", func() *Agent {
|
||||
dir := t.TempDir()
|
||||
ms, _ := media.New(filepath.Join(dir, "m"), 0)
|
||||
t.Cleanup(func() { ms.Close() })
|
||||
a := &Agent{mediaStore: ms, mediaGCInterval: 0}
|
||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||
return a
|
||||
}()},
|
||||
// heldMediaDigests 汇总三层记忆持有的媒体:只有这些才可被召回。
|
||||
func TestHeldMediaDigests_CollectsAcrossLayers(t *testing.T) {
|
||||
a, ms := newMediaLoopAgent(t)
|
||||
d1, _ := ms.Put([]byte("ctx-layer"), media.Item{MIME: "image/png"})
|
||||
d2, _ := ms.Put([]byte("doc-layer"), media.Item{MIME: "image/png"})
|
||||
d3, _ := ms.Put([]byte("graph-layer"), media.Item{MIME: "image/png"})
|
||||
d4, _ := ms.Put([]byte("orphan"), media.Item{MIME: "image/png"})
|
||||
|
||||
a.context = NewRelevanceContext("", memory.NewStaticEmbedder(""))
|
||||
a.context.Append(ContextEvent{Input: "带图的一轮", Blocks: []memory.MemoryBlock{
|
||||
{ID: "blk_ctx", Modality: memory.BlockImage, PayloadDigest: d1},
|
||||
}})
|
||||
|
||||
dir := t.TempDir()
|
||||
bo, ok := a.blockFromDigest(d2)
|
||||
if !ok {
|
||||
t.Fatal("blockFromDigest 失败")
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
done := make(chan struct{})
|
||||
go func(a *Agent) { a.mediaGCLoop(); close(done) }(c.agent)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("%s: mediaGCLoop 未立即返回(会空转 goroutine)", c.name)
|
||||
}
|
||||
c.agent.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaGCLoop_ClearsOrphansKeepsReferenced(t *testing.T) {
|
||||
a, ms := newMediaLoopAgent(t, 50*time.Millisecond, 0, false)
|
||||
|
||||
kept, _ := ms.Put([]byte("referenced"), media.Item{MIME: "image/png"})
|
||||
if err := ms.AddRef(kept, media.OwnerContext, "evt-1"); err != nil {
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orphan, _ := ms.Put([]byte("orphaned"), media.Item{MIME: "image/png"})
|
||||
|
||||
go a.mediaGCLoop()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := ms.Stat(orphan); err != nil {
|
||||
break // 孤儿已被清
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
defer ds.Stop()
|
||||
if err := ds.Insert(&document.Doc{ID: "doc_1", Summary: "s", Blocks: []memory.MemoryBlock{bo}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.cancel()
|
||||
a.docStore = ds
|
||||
|
||||
if _, err := ms.Stat(orphan); err == nil {
|
||||
t.Fatal("无引用项应被 GC 清理")
|
||||
}
|
||||
// 关键不变量:有引用的内容永不被删,否则记忆里的 digest 成悬空指针
|
||||
if _, err := ms.Get(kept); err != nil {
|
||||
t.Fatalf("被引用的内容不该被清: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaGCLoop_MinAgeProtectsFresh(t *testing.T) {
|
||||
// minAge 保护刚 Put 还没来得及 AddRef 的项——它们 refcount 也是 0
|
||||
a, ms := newMediaLoopAgent(t, 30*time.Millisecond, time.Hour, false)
|
||||
|
||||
d, _ := ms.Put([]byte("just-arrived"), media.Item{MIME: "image/png"})
|
||||
|
||||
go a.mediaGCLoop()
|
||||
time.Sleep(400 * time.Millisecond) // 足够跑十几轮 GC
|
||||
a.cancel()
|
||||
|
||||
if _, err := ms.Get(d); err != nil {
|
||||
t.Fatalf("minAge 内的新项不该被清: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) {
|
||||
// describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路)
|
||||
a, _ := newMediaLoopAgent(t, 0, 0, false)
|
||||
done := make(chan struct{})
|
||||
go func() { a.mediaDescribeLoop(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("describe 关闭时 mediaDescribeLoop 未立即返回")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) {
|
||||
// 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理——
|
||||
// 配置好之后必须还能被捡起来。
|
||||
a, ms := newMediaLoopAgent(t, 0, 0, true)
|
||||
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
|
||||
// providerManager 为 nil → resolveModalFallback 返回 nil
|
||||
a.describePendingMedia()
|
||||
|
||||
it, err := ms.Stat(d)
|
||||
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.Description != "" || it.DescribedBy != "" {
|
||||
t.Fatalf("无可用源时不该写描述: %+v", it)
|
||||
defer g.Close()
|
||||
if err := g.PutMemoryBlocks([]memory.MemoryBlock{
|
||||
{ID: "blk_g", Modality: memory.BlockImage, PayloadDigest: d3},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, _ := ms.Pending(10)
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("项应仍在待描述队列里,实际 %d 条", len(pending))
|
||||
a.memory = g
|
||||
|
||||
held := a.heldMediaDigests()
|
||||
for _, want := range []string{d1, d2, d3} {
|
||||
if !held[want] {
|
||||
t.Errorf("层次持有 %s 却不在结果里: %v", shortDigest(want), held)
|
||||
}
|
||||
}
|
||||
if held[d4] {
|
||||
t.Errorf("无人持有的 %s 不该出现在结果里", shortDigest(d4))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) {
|
||||
// video/other 大类没有可用的描述通道,必须标记掉,
|
||||
// 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。
|
||||
a, ms := newMediaLoopAgent(t, 0, 0, true)
|
||||
// fakeSpace 是一个只覆盖图像的假统一空间,用来验证「不在本空间」与
|
||||
// 「本次失败」必须被区分对待。
|
||||
type fakeSpace struct{}
|
||||
|
||||
other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"})
|
||||
a.describePendingMedia()
|
||||
func (fakeSpace) VectorizeDense(string) ([]float64, error) { return []float64{1, 0}, nil }
|
||||
|
||||
it, err := ms.Stat(other)
|
||||
func (fakeSpace) EmbedImageDense(_ []byte, mime string) ([]float64, error) {
|
||||
if strings.HasPrefix(mime, "audio/") || strings.HasPrefix(mime, "video/") {
|
||||
return nil, fmt.Errorf("%w: %s", vector.ErrModalityUnsupported, mime)
|
||||
}
|
||||
return []float64{1, 0}, nil
|
||||
}
|
||||
|
||||
func (fakeSpace) Fingerprint() string { return "fake-space" }
|
||||
func (fakeSpace) Dim() int { return 2 }
|
||||
func (fakeSpace) Loaded() bool { return true }
|
||||
func (fakeSpace) Close() {}
|
||||
|
||||
// TestReembedStaleMedia_SkipsUnsupportedWithoutFaking 验证向量迁移不会:
|
||||
// - 把音频当失败反复重试;
|
||||
// - 更不能拿另一个模型的向量顶替音频(那会污染统一空间且静默)。
|
||||
func TestReembedStaleMedia_SkipsUnsupportedWithoutFaking(t *testing.T) {
|
||||
a, ms := newMediaLoopAgent(t)
|
||||
img, _ := ms.Put([]byte("img-bytes"), media.Item{MIME: "image/png"})
|
||||
aud, _ := ms.Put([]byte("aud-bytes"), media.Item{MIME: "audio/wav"})
|
||||
|
||||
a.multimodalSpace = fakeSpace{}
|
||||
a.reembedStaleMedia()
|
||||
|
||||
it, err := ms.Stat(img)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.DescribedBy != "unsupported" {
|
||||
t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy)
|
||||
if len(it.Vec) != 2 || it.VecModel != "fake-space" {
|
||||
t.Fatalf("图像应拿到本空间向量,实际 vec=%v model=%q", it.Vec, it.VecModel)
|
||||
}
|
||||
// 标记后必须退出待描述队列,否则每轮都被取出来重试、永久占着
|
||||
// LIMIT 的名额,真正需要描述的新项永远轮不到。
|
||||
pending, _ := ms.Pending(10)
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending))
|
||||
|
||||
audIt, err := ms.Stat(aud)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(audIt.Vec) != 0 || audIt.VecModel != "" {
|
||||
t.Fatalf("音频不得被写入任何向量(不能用别的模型顶替),实际 vec=%v model=%q",
|
||||
audIt.Vec, audIt.VecModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) {
|
||||
a, _ := newMediaLoopAgent(t, 0, 0, true)
|
||||
a.describePendingMedia() // 不该 panic
|
||||
// payloadHeld 是删除前的活查询。
|
||||
func TestPayloadHeld(t *testing.T) {
|
||||
a, ms := newMediaLoopAgent(t)
|
||||
d, _ := ms.Put([]byte("held"), media.Item{MIME: "image/png"})
|
||||
if a.payloadHeld(d) {
|
||||
t.Fatal("尚无块持有时不该报已持有")
|
||||
}
|
||||
|
||||
a.context = NewRelevanceContext("", memory.NewStaticEmbedder(""))
|
||||
a.context.Append(ContextEvent{Input: "x", Blocks: []memory.MemoryBlock{
|
||||
{ID: "blk_1", Modality: memory.BlockImage, PayloadDigest: d},
|
||||
}})
|
||||
if !a.payloadHeld(d) {
|
||||
t.Fatal("L0 持有却报未持有")
|
||||
}
|
||||
if a.payloadHeld("") {
|
||||
t.Fatal("空 digest 应为 false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestForgetPayloads_DeletesOnlyUnheldContent 验证删除语义:
|
||||
// 块被删除后内容才被删;仍被其它记忆块共享的 digest 不会被误删。
|
||||
func TestForgetPayloads_DeletesOnlyUnheldContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ms.Close()
|
||||
|
||||
d1, _ := ms.Put([]byte("held-by-graph"), media.Item{MIME: "image/png"})
|
||||
d2, _ := ms.Put([]byte("being-forgotten"), media.Item{MIME: "image/png"})
|
||||
|
||||
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
if err := g.PutMemoryBlocks([]memory.MemoryBlock{
|
||||
{ID: "blk_keep", Modality: memory.BlockImage, PayloadDigest: d1},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := &Agent{mediaStore: ms, memory: g}
|
||||
a.forgetPayloads([]string{d1, d2})
|
||||
|
||||
if _, err := ms.Stat(d1); err != nil {
|
||||
t.Fatalf("仍被 L3 块持有的内容不该被删: %v", err)
|
||||
}
|
||||
if _, err := ms.Stat(d2); err == nil {
|
||||
t.Fatal("无人持有的内容应被删除")
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,28 +4,70 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// blockSeq 保证块 ID 全局唯一(Graph memory_blocks 以 id 为主键)。
|
||||
var blockSeq int64
|
||||
|
||||
func newBlockID() string {
|
||||
return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&blockSeq, 1))
|
||||
}
|
||||
|
||||
// blockModalityOf 把 CAS 媒体大类映射为一等记忆块模态。
|
||||
func blockModalityOf(k media.Kind) memory.BlockModality {
|
||||
switch k {
|
||||
case media.KindImage:
|
||||
return memory.BlockImage
|
||||
case media.KindVideo:
|
||||
return memory.BlockVideo
|
||||
case media.KindAudio:
|
||||
return memory.BlockAudio
|
||||
default:
|
||||
return memory.BlockText
|
||||
}
|
||||
}
|
||||
|
||||
// blockFromDigest 把一份已入库媒体变成一等记忆块。
|
||||
// 块携带 digest/向量/fingerprint;CAS 只提供字节与元数据,不参与生命周期。
|
||||
func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) {
|
||||
if a.mediaStore == nil || digest == "" {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
it, err := a.mediaStore.Stat(digest)
|
||||
if err != nil || it == nil {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
return memory.MemoryBlock{
|
||||
ID: newBlockID(),
|
||||
Modality: blockModalityOf(it.Kind),
|
||||
PayloadDigest: it.Digest,
|
||||
MIME: it.MIME,
|
||||
Size: it.Size,
|
||||
Width: it.Width,
|
||||
Height: it.Height,
|
||||
Vector: it.Vec,
|
||||
Fingerprint: it.VecModel,
|
||||
Tool: it.Tool,
|
||||
CreatedAt: it.FirstSeen,
|
||||
}, true
|
||||
}
|
||||
|
||||
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
|
||||
// 并让 L0 的 ContextEvent 记住它们的 digest。
|
||||
// 并让 L0 的 ContextEvent 直接持有一等记忆块。
|
||||
//
|
||||
// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆——
|
||||
// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入
|
||||
// (SetToolBlocks)。两条都在这里收口:从 data URL 取出字节存进 CAS,
|
||||
// 用其向量构造一等记忆块挂到当轮 ContextEvent 上;事件被 Prune 时
|
||||
// 块随之迁移到 L2 文档。
|
||||
//
|
||||
// 1. 用户直接发图 → processInput/resolveInput → mediaToBlocks
|
||||
// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
|
||||
// base64 随 message 数组发给模型后就丢了。
|
||||
// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
|
||||
// ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。
|
||||
//
|
||||
// 于是下一轮对话起,模型能看到的只有一句路径或一句 alt。那个文件被删、
|
||||
// 被覆盖,或者本来就是 /tmp 下的临时产物,连线索都断了。
|
||||
//
|
||||
// 现在两条路都在同一处收口:从 ContentBlock 的 data URL 取出字节存进 CAS,
|
||||
// digest 挂到当轮 ContextEvent 上;事件被 Prune 归档进 L2 时引用随之转移。
|
||||
// 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的
|
||||
// 统一空间向量被检索,描述式索引是将就方案。
|
||||
|
||||
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。
|
||||
//
|
||||
@ -63,14 +105,38 @@ func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) [
|
||||
log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 入库即算一次多模态坐标并缓存(多模态空间可用时)。
|
||||
// 之后 doc_query / memory_recall / 内部召回直接复用 SetVec 的缓存坐标,
|
||||
// 不重复跑 ONNX;模型切换由启动时的 reembedStaleMedia 补算。
|
||||
a.embedMediaOnIngest(d, mime, data)
|
||||
digests = append(digests, d)
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
// embedMediaOnIngest 给刚入库的图片立即计算多模态坐标并缓存。
|
||||
// 只在 多模态空间可用且为图像时执行;音频/未配置时静默跳过(保持既有行为)。
|
||||
func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) {
|
||||
if a.multimodalSpace == nil || !a.multimodalSpace.Loaded() {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
return
|
||||
}
|
||||
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
|
||||
if err != nil {
|
||||
log.Printf("[media] 入库嵌入失败 %s: %v", shortDigest(digest), err)
|
||||
return
|
||||
}
|
||||
if err := a.mediaStore.SetVec(digest, vec, a.multimodalSpace.Fingerprint()); err != nil {
|
||||
log.Printf("[media] 入库写向量失败 %s: %v", shortDigest(digest), err)
|
||||
}
|
||||
}
|
||||
|
||||
// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。
|
||||
//
|
||||
// 为何要缓存而不是当场 AddRef:媒体在 process() 执行期间被捕获,而承载它的
|
||||
// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的
|
||||
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||
// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。
|
||||
func (a *Agent) stageMediaDigests(digests ...string) {
|
||||
@ -90,12 +156,10 @@ func (a *Agent) drainMediaDigests() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// bindEventMedia 把 digest 列表登记到某个 ContextEvent 上。
|
||||
// bindEventMedia 把本轮捕获的媒体变成一等记忆块,直接挂到 ContextEvent 上。
|
||||
//
|
||||
// 双向落地:evt.Media 让事件自己记得引了哪些媒体(随 context.json 持久化),
|
||||
// media_refs 表让 CAS 侧知道谁在引用(GC 据此判断能不能清)。
|
||||
// 两边都写才闭环——只写一边的话,要么 GC 会误删仍被记忆引用的内容,
|
||||
// 要么孤儿永远清不掉。
|
||||
// 块存储在事件自身(随 context.json 持久化),不再写 media_refs:
|
||||
// 存活与否由“三层记忆块是否持有这个 digest”决定,不维护引用账本。
|
||||
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
|
||||
if a.mediaStore == nil || evt == nil || len(digests) == 0 {
|
||||
return
|
||||
@ -104,61 +168,29 @@ func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
|
||||
evt.ID = newEventID()
|
||||
}
|
||||
for _, d := range digests {
|
||||
if err := a.mediaStore.AddRef(d, media.OwnerContext, evt.ID); err != nil {
|
||||
log.Printf("[media] AddRef 失败 (%s → %s): %v", shortDigest(d), evt.ID, err)
|
||||
b, ok := a.blockFromDigest(d)
|
||||
if !ok {
|
||||
log.Printf("[media] 块构造失败 (%s)", shortDigest(d))
|
||||
continue
|
||||
}
|
||||
evt.Media = append(evt.Media, d)
|
||||
evt.Blocks = append(evt.Blocks, b)
|
||||
}
|
||||
}
|
||||
|
||||
// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。
|
||||
// mediaLabel 渲染一行媒体标签,供提示词告知"这条记忆带着哪份媒体"。
|
||||
//
|
||||
// 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。
|
||||
// blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,
|
||||
// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。
|
||||
func (a *Agent) mediaSummaryForEvent(digests []string) string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for _, d := range digests {
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "媒体内容:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// mediaMarkerLine 为一份媒体生成一行标记文本 `[<mime> <短digest>] <描述>`。
|
||||
//
|
||||
// 这是媒体标记格式的唯一生成处。此前 mediaSummaryForEvent 与
|
||||
// mediaContextForSentences 各拼一份,改动截断长度或分隔符时只改一处,
|
||||
// 另一处写出的标记就再也解析不回来——而解析失败是静默的(引用挂不上)。
|
||||
//
|
||||
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出一条指向虚无的标记。
|
||||
func (a *Agent) mediaMarkerLine(digest string) string {
|
||||
if a.mediaStore == nil {
|
||||
return ""
|
||||
}
|
||||
it, err := a.mediaStore.Stat(digest)
|
||||
if err != nil || it == nil {
|
||||
// 不再包含任何生成的描述文本:图片只按自己的向量被检索,标签仅提供
|
||||
// MIME 与短 digest,让模型知道有这份媒体、可据 digest 取回字节。
|
||||
// 查不到返回空串:内容可能已被删除,不该造出一条指向虚无的标签。
|
||||
func mediaLabel(it *media.Item) string {
|
||||
if it == nil {
|
||||
return ""
|
||||
}
|
||||
label := string(it.Kind)
|
||||
if it.MIME != "" {
|
||||
label = it.MIME
|
||||
}
|
||||
desc := it.Description
|
||||
if desc == "" {
|
||||
// 「已入库但还没描述」与「压根没有媒体」必须可区分:描述由后台循环
|
||||
// 异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
|
||||
desc = "(未描述)"
|
||||
}
|
||||
return fmt.Sprintf("[%s %s] %s", label, shortDigest(digest), desc)
|
||||
return fmt.Sprintf("[%s %s]", label, shortDigest(it.Digest))
|
||||
}
|
||||
|
||||
// newEventID 生成 ContextEvent 的稳定标识。
|
||||
|
||||
@ -24,7 +24,7 @@ import (
|
||||
func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatalf("media.New: %v", err)
|
||||
}
|
||||
@ -35,7 +35,6 @@ func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) {
|
||||
mediaStore: ms,
|
||||
context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb),
|
||||
}
|
||||
a.context.SetMediaStore(ms)
|
||||
return a, ms
|
||||
}
|
||||
|
||||
@ -104,11 +103,11 @@ func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) {
|
||||
// bindEventMedia 对 nil store 也必须安全
|
||||
evt := &ContextEvent{}
|
||||
a.bindEventMedia(evt, []string{"deadbeef"})
|
||||
if len(evt.Media) != 0 || evt.ID != "" {
|
||||
if len(evt.Blocks) != 0 || evt.ID != "" {
|
||||
t.Fatalf("nil store 时不该改动事件: %+v", evt)
|
||||
}
|
||||
if s := a.mediaSummaryForEvent([]string{"deadbeef"}); s != "" {
|
||||
t.Fatalf("nil store 时摘要应为空,得到 %q", s)
|
||||
if s := mediaLabel(nil); s != "" {
|
||||
t.Fatalf("nil 媒体应产出空标签,得到 %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,7 +151,7 @@ func TestStageDrainMediaDigests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) {
|
||||
func TestBindEventMedia_CreatesBlocks(t *testing.T) {
|
||||
a, ms := newTestAgentWithMedia(t)
|
||||
|
||||
d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
@ -166,17 +165,11 @@ func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) {
|
||||
if evt.ID == "" {
|
||||
t.Fatal("应懒生成事件 ID")
|
||||
}
|
||||
if len(evt.Media) != 1 || evt.Media[0] != d {
|
||||
t.Fatalf("事件应记住 digest: %+v", evt.Media)
|
||||
if len(evt.Blocks) != 1 || evt.Blocks[0].PayloadDigest != d {
|
||||
t.Fatalf("事件应持有一等记忆块: %+v", evt.Blocks)
|
||||
}
|
||||
// 双向落地:CAS 侧也要知道谁在引用,否则 GC 会误删
|
||||
it, _ := ms.Stat(d)
|
||||
if it.RefCount != 1 {
|
||||
t.Fatalf("引用计数应为 1,实际 %d", it.RefCount)
|
||||
}
|
||||
refs, _ := ms.Refs(media.OwnerContext, evt.ID)
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("media_refs 应有 1 条,实际 %d", len(refs))
|
||||
if evt.Blocks[0].Modality != memory.BlockImage || evt.Blocks[0].MIME != "image/png" {
|
||||
t.Fatalf("块元数据不对: %+v", evt.Blocks[0])
|
||||
}
|
||||
}
|
||||
|
||||
@ -190,120 +183,38 @@ func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) {
|
||||
// 方案 C 的核心:描述文本才是持久语义记忆,blob 只是缓存。
|
||||
// blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里可被检索。
|
||||
func TestMediaLabel_NoGeneratedDescription(t *testing.T) {
|
||||
// 标签只用来告诉模型「这条记忆带着哪份媒体、可用该 digest 取回字节」。
|
||||
// 它不包含任何生成的描述:描述式索引是把就机制,已彻底废弃。
|
||||
a, ms := newTestAgentWithMedia(t)
|
||||
|
||||
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
if s := a.mediaSummaryForEvent([]string{d}); s == "" {
|
||||
t.Fatal("未描述项也应产出一行(标注未描述)")
|
||||
}
|
||||
|
||||
ms.Describe(d, "一张紫蓝红三色带图", "visionllm")
|
||||
s := a.mediaSummaryForEvent([]string{d})
|
||||
if s == "" {
|
||||
t.Fatal("应产出摘要")
|
||||
}
|
||||
if !strings.Contains(s, "紫蓝红三色带图") {
|
||||
t.Fatalf("摘要应含描述文本: %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "image/png") {
|
||||
t.Fatalf("摘要应含 MIME 标注: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrune_TransfersMediaRefsToDocument(t *testing.T) {
|
||||
// L0→L2 归档:媒体引用从 context 事件转到归档文档,
|
||||
// 且转移期间内容必须始终可读(先挂后销,不留归零窗口)。
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ms.Close()
|
||||
|
||||
emb := memory.NewStaticEmbedder()
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"))
|
||||
if err := docStore.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
|
||||
rc.SetMediaStore(ms)
|
||||
|
||||
a := &Agent{mediaStore: ms, context: rc}
|
||||
|
||||
// 造一张被引用的图,挂到一条会被淘汰的老事件上
|
||||
payload := []byte("archived-image")
|
||||
d, _ := ms.Put(payload, media.Item{MIME: "image/png"})
|
||||
oldEvt := ContextEvent{
|
||||
Timestamp: time.Now().Add(-time.Hour),
|
||||
Source: "qq",
|
||||
Input: "很久以前的一张图",
|
||||
}
|
||||
a.bindEventMedia(&oldEvt, []string{d})
|
||||
oldEvtID := oldEvt.ID
|
||||
rc.Append(oldEvt)
|
||||
|
||||
// 再塞满 12 条新事件,逼 Prune 把老事件淘汰
|
||||
// (Prune 保护最近 10 条,topK 传 5 使候选全部进归档)
|
||||
for i := 0; i < 12; i++ {
|
||||
rc.Append(ContextEvent{
|
||||
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
|
||||
Source: "qq",
|
||||
Input: "无关内容",
|
||||
})
|
||||
}
|
||||
|
||||
archived := rc.Prune("完全不相关的查询", 5, docStore)
|
||||
if archived == 0 {
|
||||
t.Fatal("应有事件被归档")
|
||||
}
|
||||
|
||||
// 关键断言:内容仍可读(引用被转走而非归零后被清)
|
||||
got, err := ms.Get(d)
|
||||
if err != nil {
|
||||
t.Fatalf("归档后内容应仍可读: %v", err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatal("内容被改")
|
||||
}
|
||||
|
||||
it, err := ms.Stat(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.RefCount < 1 {
|
||||
t.Fatalf("引用应转移而非归零,实际 refcount=%d", it.RefCount)
|
||||
s := mediaLabel(it)
|
||||
if s == "" {
|
||||
t.Fatal("应产出标签")
|
||||
}
|
||||
// 原 context 引用应已注销
|
||||
if refs, _ := ms.Refs(media.OwnerContext, oldEvtID); len(refs) != 0 {
|
||||
t.Fatalf("原事件引用应已注销,仍有 %d 条", len(refs))
|
||||
if !strings.Contains(s, "image/png") {
|
||||
t.Fatalf("标签应含 MIME 标注: %q", s)
|
||||
}
|
||||
// 应挂到某个 document owner 上
|
||||
var docOwned bool
|
||||
docs := docStore.RecentDocs(10)
|
||||
for _, doc := range docs {
|
||||
if refs, _ := ms.Refs(media.OwnerDocument, doc.ID); len(refs) > 0 {
|
||||
docOwned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !docOwned {
|
||||
t.Fatal("引用应已挂到归档文档上")
|
||||
if !strings.Contains(s, shortDigest(d)) {
|
||||
t.Fatalf("标签应含短 digest 供反查: %q", s)
|
||||
}
|
||||
_ = a
|
||||
}
|
||||
|
||||
func TestPrune_NilMediaStoreStillArchives(t *testing.T) {
|
||||
// 媒体存储未启用时归档链路必须照常工作
|
||||
dir := t.TempDir()
|
||||
emb := memory.NewStaticEmbedder()
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"))
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
|
||||
// 刻意不 SetMediaStore
|
||||
|
||||
for i := 0; i < 15; i++ {
|
||||
rc.Append(ContextEvent{
|
||||
@ -317,13 +228,13 @@ func TestPrune_NilMediaStoreStillArchives(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextEvent_MediaFieldRoundTrip(t *testing.T) {
|
||||
// context.json 加字段必须向后兼容:存量文件读回来 Media 为空、ID 为空,
|
||||
func TestContextEvent_BlocksFieldRoundTrip(t *testing.T) {
|
||||
// context.json 加字段必须向后兼容:存量文件读回来 Blocks 为空、ID 为空,
|
||||
// 不影响任何既有行为。
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "context.json")
|
||||
|
||||
// 写一份"存量格式"(无 id / media 字段)
|
||||
// 写一份"存量格式"(无 id / blocks 字段)
|
||||
legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]`
|
||||
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
@ -335,8 +246,8 @@ func TestContextEvent_MediaFieldRoundTrip(t *testing.T) {
|
||||
t.Fatalf("应读回 1 条,实际 %d", rc.Len())
|
||||
}
|
||||
|
||||
// 新写入带媒体的事件,再读回
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
// 新写入带记忆块的事件,再读回
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -352,4 +263,71 @@ func TestContextEvent_MediaFieldRoundTrip(t *testing.T) {
|
||||
if rc2.Len() != 2 {
|
||||
t.Fatalf("应有 2 条,实际 %d", rc2.Len())
|
||||
}
|
||||
var persisted int
|
||||
for _, e := range rc2.Recent(10) {
|
||||
persisted += len(e.Blocks)
|
||||
}
|
||||
if persisted != 1 {
|
||||
t.Fatalf("块应随 context.json 持久化,实际 %d 个", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneMigratesBlocksToDocument(t *testing.T) {
|
||||
// 一等记忆块的 L0→L2 迁移:块随事件离开 Context、进入 Document,
|
||||
// 身份(ID/模态/digest/向量)原样保留;同一块不能同时留在两层。
|
||||
// 这条路径不依赖 media_refs/ref_count。
|
||||
dir := t.TempDir()
|
||||
emb := memory.NewStaticEmbedder()
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
|
||||
|
||||
block := memory.MemoryBlock{
|
||||
ID: "blk_migrate_1", Modality: memory.BlockImage,
|
||||
PayloadDigest: "deadbeef", MIME: "image/png", Size: 42,
|
||||
Vector: []float64{0.1, 0.2, 0.3}, Fingerprint: "qwen:test",
|
||||
}
|
||||
rc.Append(ContextEvent{
|
||||
Timestamp: time.Now().Add(-time.Hour),
|
||||
Source: "qq", Input: "很久以前的一张图",
|
||||
Blocks: []memory.MemoryBlock{block},
|
||||
})
|
||||
for i := 0; i < 12; i++ {
|
||||
rc.Append(ContextEvent{
|
||||
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
|
||||
Source: "qq", Input: "无关内容",
|
||||
})
|
||||
}
|
||||
|
||||
if n := rc.Prune("完全不相关的查询", 5, docStore); n == 0 {
|
||||
t.Fatal("应有事件被归档")
|
||||
}
|
||||
|
||||
// 块应已到达 L2,且身份不变。
|
||||
var found *document.Doc
|
||||
for _, d := range docStore.RecentDocs(20) {
|
||||
if len(d.Blocks) > 0 {
|
||||
found = d
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("归档文档应持有一等记忆块")
|
||||
}
|
||||
if len(found.Blocks) != 1 {
|
||||
t.Fatalf("文档应有 1 个块,实际 %d", len(found.Blocks))
|
||||
}
|
||||
got := found.Blocks[0]
|
||||
if got.ID != block.ID || got.Modality != block.Modality || got.PayloadDigest != block.PayloadDigest || got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) {
|
||||
t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", got, block)
|
||||
}
|
||||
|
||||
// 同一块不能同时留在 L0。
|
||||
for _, e := range rc.Recent(100) {
|
||||
if len(e.Blocks) > 0 {
|
||||
t.Fatalf("块仍留在 L0(同一块同时存在于两层): %+v", e.Blocks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
@ -78,7 +78,10 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||||
return fmt.Sprintf("[%s] 通道发送结果未确认:%s", channel, note)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
|
||||
// 成功回执:只返回极简标记,不回传完整插件响应。
|
||||
// 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执
|
||||
// 会驱动模型继续调用 output_send(回声效应),是 output loop 的根源之一。
|
||||
return "ok"
|
||||
}
|
||||
|
||||
a.io.EmitTextTo("agent_io", channel, payload)
|
||||
|
||||
54
internal/agent/core/persona.go
Normal file
54
internal/agent/core/persona.go
Normal file
@ -0,0 +1,54 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// PersonaStore 是人格设定的读写面。
|
||||
//
|
||||
// 首启门禁(buildSystemPrompt)与 persona_set 工具都通过它工作,实现在 cmd/homed:
|
||||
// 读写配置项 core.agent.personal_prompt 与一次性标记 core.internal.persona_initialized
|
||||
// (落库逻辑与 WebUI 向导共用 internal/config 的实现)。
|
||||
//
|
||||
// 为什么放在内核而不是某个通道插件:人格是**任何通道都要问一次**的事。
|
||||
// 系统提示词每轮重建,门禁放在这里,WebUI / QQ / CLI / ACP / 邮件等全部通道自动覆盖。
|
||||
type PersonaStore interface {
|
||||
// PersonaInitialized 报告人格是否已确认(向导或工具已问过)。
|
||||
PersonaInitialized() bool
|
||||
// SetPersona 落库人格并打一次性标记,返回是否需要重启才生效。
|
||||
SetPersona(mode, content string) (restartRequired bool, err error)
|
||||
}
|
||||
|
||||
// executePersonaTool 落地首启人格设定。
|
||||
//
|
||||
// 成功即打一次性标记 → 之后 buildSystemPrompt 不再要求模型询问人格。
|
||||
// custom 模式返回「需重启生效」:人格在 homed 启动时载入。
|
||||
func (a *Agent) executePersonaTool(tc agentAPI.ToolCall) string {
|
||||
if a.personaStore == nil {
|
||||
return "人格设定不可用:内核未接入配置"
|
||||
}
|
||||
mode, _ := tc.Arguments["mode"].(string)
|
||||
content, _ := tc.Arguments["content"].(string)
|
||||
mode = strings.TrimSpace(mode)
|
||||
restart, err := a.personaStore.SetPersona(mode, content)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("人格设定失败:%v", err)
|
||||
}
|
||||
switch mode {
|
||||
case "custom":
|
||||
msg := "已保存自定义人格"
|
||||
if restart {
|
||||
msg += ";**重启 homed 后生效**(人格在启动时载入)"
|
||||
}
|
||||
return msg
|
||||
case "default":
|
||||
return "已确认使用默认人格"
|
||||
case "later":
|
||||
return "已记为「以后再说」,继续使用默认人格"
|
||||
default:
|
||||
return "已保存人格设定"
|
||||
}
|
||||
}
|
||||
112
internal/agent/core/persona_test.go
Normal file
112
internal/agent/core/persona_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// newTestAgent 造一个最小可用的 Agent(buildToolDefs 要求 io 非 nil)。
|
||||
func newTestAgent(st PersonaStore) *Agent {
|
||||
return &Agent{io: agentIO.NewIOManager(), personaStore: st}
|
||||
}
|
||||
|
||||
// fakePersonaStore 记录调用并可控地报告「是否已确认」。
|
||||
type fakePersonaStore struct {
|
||||
initialized bool
|
||||
mode string
|
||||
content string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakePersonaStore) PersonaInitialized() bool { return f.initialized }
|
||||
|
||||
func (f *fakePersonaStore) SetPersona(mode, content string) (bool, error) {
|
||||
f.calls++
|
||||
f.mode, f.content = mode, content
|
||||
f.initialized = true
|
||||
return mode == "custom", nil
|
||||
}
|
||||
|
||||
// 首启门禁:人格未确认时,**任何通道**的系统提示词都必须带上「去问用户」的指令;
|
||||
// 确认后必须消失(否则会每轮反复追问)。
|
||||
func TestPersonaOnboardingGateInSystemPrompt(t *testing.T) {
|
||||
st := &fakePersonaStore{}
|
||||
a := newTestAgent(st)
|
||||
|
||||
p := a.buildSystemPrompt("", "你好")
|
||||
if !strings.Contains(p, "首启人格设定") || !strings.Contains(p, "persona_set") {
|
||||
t.Fatalf("未确认人格时提示词应要求模型询问并调用 persona_set,实际缺少该段")
|
||||
}
|
||||
|
||||
// 模型落地后(标记置位)不再出现
|
||||
if out := a.executePersonaTool(agentAPI.ToolCall{Name: "persona_set",
|
||||
Arguments: map[string]interface{}{"mode": "default"}}); !strings.Contains(out, "默认人格") {
|
||||
t.Fatalf("persona_set(default) 回执不对: %s", out)
|
||||
}
|
||||
if !st.initialized {
|
||||
t.Fatal("落库后应置位标记")
|
||||
}
|
||||
if p2 := a.buildSystemPrompt("", "你好"); strings.Contains(p2, "首启人格设定") {
|
||||
t.Fatal("人格已确认后不应再要求询问")
|
||||
}
|
||||
|
||||
// 未接入配置(personaStore 为 nil)时,门禁与工具都必须静默关闭
|
||||
b := newTestAgent(nil)
|
||||
if pb := b.buildSystemPrompt("", "你好"); strings.Contains(pb, "首启人格设定") {
|
||||
t.Fatal("未接入配置时不应出现首启门禁")
|
||||
}
|
||||
if out := b.executePersonaTool(agentAPI.ToolCall{Name: "persona_set"}); !strings.Contains(out, "不可用") {
|
||||
t.Fatalf("未接入配置时工具应回明确错误,实际: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// persona_set 的三选一语义与回执。
|
||||
func TestPersonaSetToolModes(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode, content, want string
|
||||
}{
|
||||
{"custom", "你是测试人格", "重启"},
|
||||
{"default", "", "默认人格"},
|
||||
{"later", "", "以后再说"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
st := &fakePersonaStore{}
|
||||
a := newTestAgent(st)
|
||||
out := a.executePersonaTool(agentAPI.ToolCall{Name: "persona_set",
|
||||
Arguments: map[string]interface{}{"mode": c.mode, "content": c.content}})
|
||||
if !strings.Contains(out, c.want) {
|
||||
t.Errorf("mode=%s 回执应含 %q,实际: %s", c.mode, c.want, out)
|
||||
}
|
||||
if st.calls != 1 || st.mode != c.mode || st.content != c.content {
|
||||
t.Errorf("mode=%s 落库参数不对: calls=%d mode=%s content=%q", c.mode, st.calls, st.mode, st.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工具 schema 必须在 catalog 里出现(模型才可能调用)。
|
||||
func TestPersonaSetToolDefPresent(t *testing.T) {
|
||||
a := newTestAgent(&fakePersonaStore{})
|
||||
found := false
|
||||
for _, td := range a.buildToolDefs() {
|
||||
if m, ok := td.(map[string]interface{}); ok {
|
||||
if fn, ok := m["function"].(map[string]interface{}); ok && fn["name"] == "persona_set" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("buildToolDefs 未包含 persona_set")
|
||||
}
|
||||
// 未接入配置时不应暴露该工具
|
||||
b := newTestAgent(nil)
|
||||
for _, td := range b.buildToolDefs() {
|
||||
if m, ok := td.(map[string]interface{}); ok {
|
||||
if fn, ok := m["function"].(map[string]interface{}); ok && fn["name"] == "persona_set" {
|
||||
t.Fatal("未接入配置时不应暴露 persona_set")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,90 @@ import (
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// continuationPlaceholder 是工具轮之后补的 user 占位内容。
|
||||
//
|
||||
// zen 兼容网关要求请求最后一条必须是 user(thinking 续写模式校验),工具轮
|
||||
// 产出 assistant/tool 结尾会被 400 拒绝;首轮 system 结尾不补,否则会覆盖
|
||||
// 真实用户输入。
|
||||
//
|
||||
// 用独立常量 + 精确等值判定,是因为这条消息是**核心自己插入的**、不是用户输入,
|
||||
// 所以可以安全地按内容识别并在补位前移除上一条,保证至多一条。
|
||||
const continuationPlaceholder = "请根据以上工具结果继续。"
|
||||
|
||||
// replyDeliveredPlaceholder 是「本批工具调用全部是输出通道发送」之后补的占位。
|
||||
//
|
||||
// 为何不能继续用通用的「请继续」:异步通道(qq/wechat)的回复**只能**经
|
||||
// output_send__* 交付(纯文本不送达,见 buildSystemPrompt 的输出规则)。于是
|
||||
// 模型「已经回复完了」的表达形式就是一个工具调用,而紧随其后的
|
||||
// 「请根据以上工具结果继续。」会被读成「还要再做一步」——能做的「一步」恰好
|
||||
// 还是再发一条消息。两者叠加成自我强化的发送循环:生产实测单轮 34 次
|
||||
// output_send__qq、持续 514 秒,直到 QQ 插件自己的循环保险拒绝发送才停下。
|
||||
//
|
||||
// 所以这里换成一条明确的终止许可:已回复完就直接返回纯文本收尾。
|
||||
const replyDeliveredPlaceholder = "若你的回复已完成,直接返回纯文本即可结束本轮,无需再调用任何工具。"
|
||||
|
||||
// continuationFor 选择工具轮之后补位的 user 占位文案。
|
||||
// replyOnly 表示上一批工具调用全部是输出通道发送(即模型刚交付了回复)。
|
||||
func continuationFor(replyOnly bool) string {
|
||||
if replyOnly {
|
||||
return replyDeliveredPlaceholder
|
||||
}
|
||||
return continuationPlaceholder
|
||||
}
|
||||
|
||||
// isOutputDeliveryTool 判断工具是否是「向输出通道交付内容」。
|
||||
// output_send__{channel}_help 只是查询用法,不算交付。
|
||||
func isOutputDeliveryTool(name string) bool {
|
||||
return strings.HasPrefix(name, "output_send__") && !strings.HasSuffix(name, "_help")
|
||||
}
|
||||
|
||||
// isContinuationPlaceholder 判断一条 user 消息是否是本机制插入的占位。
|
||||
// 只按两个常量精确匹配,不碰任何真实用户消息。
|
||||
func isContinuationPlaceholder(m agentAPI.Message) bool {
|
||||
return m.Role == "user" &&
|
||||
(m.Content == continuationPlaceholder || m.Content == replyDeliveredPlaceholder)
|
||||
}
|
||||
|
||||
// toolOutputForQuery 返回用于相关性计算的工具输出**有效内容**。
|
||||
//
|
||||
// 为什么要过 Cleaner 而不是直接用原始 result:ContextPolicy=prune 的入参是
|
||||
// **相关性查询向量**——它决定保留/归档哪些上下文事件。原始工具输出里混着
|
||||
// ANSI 转义、base64、JSON 包装等噪声,直接拿去向量化会让打分失真。
|
||||
// 而 ToolDef.Cleaner 的契约本就写着“仅在向量化/jieba/蒸馏时调用”,裁剪正是
|
||||
// 在向量化,所以这里必须过它(此前只在构建事件向量时用了,裁剪查询漏了)。
|
||||
//
|
||||
// Cleaner 未注册或 RPC 失败时回退原文(清洗是计算层优化,不能因此丢内容);
|
||||
// 返回空串时也回退——空串会让查询向量退化成零向量,裁剪就失去判据。
|
||||
func (a *Agent) toolOutputForQuery(toolName, raw string) string {
|
||||
if a.stageHost == nil {
|
||||
return raw
|
||||
}
|
||||
cleaner := a.stageHost.ToolDefCleaner(toolName)
|
||||
if cleaner == nil {
|
||||
return raw
|
||||
}
|
||||
if cleaned := cleaner(raw); cleaned != "" {
|
||||
return cleaned
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// dropContinuationPlaceholders 移除此前由本机制插入的 user 占位。
|
||||
//
|
||||
// 为什么必须移除而不仅仅是“不再追加”:`msgs` 在循环外创建、循环内只增不减,
|
||||
// 占位是核心自己插的、不是用户说的话。不移除的话,prompt 里就会线性叠上
|
||||
// N 条一模一样的“继续”,把前缀上下文(含记忆注入)往后挤。
|
||||
func dropContinuationPlaceholders(msgs []agentAPI.Message) []agentAPI.Message {
|
||||
out := msgs[:0]
|
||||
for _, m := range msgs {
|
||||
if isContinuationPlaceholder(m) {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@ -63,6 +147,9 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
}
|
||||
|
||||
// lastBatchReplyOnly 记录上一批工具调用是否全部是输出通道发送。
|
||||
lastBatchReplyOnly := false
|
||||
|
||||
for turn := 0; ; turn++ {
|
||||
for _, interrupt := range a.drainInterrupts() {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
@ -75,10 +162,17 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
|
||||
// 注意:仅当尾部确为工具轮产物(assistant/tool)时才补位;首轮 system 上下文结尾不补,
|
||||
// 否则会错误覆盖实际用户输入(如 injectSourceContext 追加的 system 说明)。
|
||||
//
|
||||
// 补位前先移除前面轮次插入的同类占位,保证占位**不随轮次线性累积**——
|
||||
// 占位是核心插的传输层附加物,不是用户发言,不该在 prompt 里叠成 N 条。
|
||||
//
|
||||
// 文案分情况:上一批全是 output_send__* 时不能说“继续”,详见
|
||||
// replyDeliveredPlaceholder 的说明。
|
||||
msgs = dropContinuationPlaceholders(msgs)
|
||||
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "user",
|
||||
Content: "请根据以上工具结果继续。",
|
||||
Content: continuationFor(lastBatchReplyOnly),
|
||||
})
|
||||
}
|
||||
|
||||
@ -243,6 +337,17 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
return resp.Content, toolsUsed, toolResults, nil
|
||||
}
|
||||
|
||||
// 本批是否全部是输出通道发送(=模型刚交付了给用户的回复)。
|
||||
// 必须在执行前判定:执行过程中的中断/拒绝分支会 continue/break,
|
||||
// 放在循环里统计会漏。
|
||||
replyOnly := true
|
||||
for _, tc := range resp.ToolCalls {
|
||||
if !isOutputDeliveryTool(tc.Name) {
|
||||
replyOnly = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
contentOnce := true
|
||||
for _, tc := range resp.ToolCalls {
|
||||
if len(a.interceptCh) > 0 {
|
||||
@ -305,6 +410,18 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
result = r
|
||||
}
|
||||
}
|
||||
// ContextPolicy: prune 工具调用后执行上下文裁剪(§13.8)
|
||||
if def := a.stageHost.ToolDef(tc.Name); def != nil && def.ContextPolicy == "prune" {
|
||||
if a.context != nil {
|
||||
topK := a.maxContextSize - 1
|
||||
if topK < 1 {
|
||||
topK = 1
|
||||
}
|
||||
// 查询向量取**清洗后**的有效内容,否则噪声(ANSI/base64/JSON
|
||||
// 包装)会把相关性打分带偏,裁掉本该保留的事件。
|
||||
a.context.Prune(a.toolOutputForQuery(tc.Name, result), topK, a.docStore)
|
||||
}
|
||||
}
|
||||
|
||||
msgContent := ""
|
||||
if contentOnce {
|
||||
@ -390,6 +507,9 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 供下一轮顶部选择补位文案。
|
||||
lastBatchReplyOnly = replyOnly
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
119
internal/agent/core/prunepolicy_test.go
Normal file
119
internal/agent/core/prunepolicy_test.go
Normal file
@ -0,0 +1,119 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// 这一组测试锁死「默认不裁剪」这条语义。
|
||||
//
|
||||
// 改动前:每条非中断输入都无条件 Prune 一次,没有任何声明能关掉它。
|
||||
// 这是破坏性行为(低相关事件被归档并从上下文移走),却无法从调用点看出
|
||||
// 「谁触发的裁剪」。改成需声明后,必须逐条验证默认值确实是不裁剪。
|
||||
func TestPruneDeclared_DefaultsToNoPrune(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
a := &Agent{io: m}
|
||||
|
||||
evt := &agentIO.InputEvent{Source: "unknown_source", Payload: map[string]interface{}{}}
|
||||
if a.pruneDeclared(evt) {
|
||||
t.Fatal("既没有通道声明也没有注入声明的输入,默认必须不裁剪")
|
||||
}
|
||||
|
||||
// 通道注册了、但策略是 none / 空:仍然不裁剪。
|
||||
m.RegisterInputChannel("quiet", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyNone})
|
||||
if a.pruneDeclared(&agentIO.InputEvent{Source: "quiet", Payload: map[string]interface{}{}}) {
|
||||
t.Fatal("ChannelDef.ContextPolicy=none 不应裁剪")
|
||||
}
|
||||
m.RegisterInputChannel("empty", pubsdk.ChannelDef{})
|
||||
if a.pruneDeclared(&agentIO.InputEvent{Source: "empty", Payload: map[string]interface{}{}}) {
|
||||
t.Fatal("ChannelDef 未设 ContextPolicy 不应裁剪")
|
||||
}
|
||||
}
|
||||
|
||||
// 通道显式声明 prune 才裁剪。
|
||||
func TestPruneDeclared_ChannelOptIn(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
m.RegisterInputChannel("noisy", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
|
||||
a := &Agent{io: m}
|
||||
|
||||
if !a.pruneDeclared(&agentIO.InputEvent{Source: "noisy", Payload: map[string]interface{}{}}) {
|
||||
t.Fatal("通道声明 prune 后应裁剪")
|
||||
}
|
||||
}
|
||||
|
||||
// 注入点声明的优先级高于通道定义:同一通道下的不同注入可以有不同意图。
|
||||
func TestPruneDeclared_InjectionOverridesChannel(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
a := &Agent{io: m}
|
||||
m.RegisterInputChannel("chan", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
|
||||
|
||||
// 注入点说 none → 即使通道说 prune 也不裁。
|
||||
evt := &agentIO.InputEvent{Source: "chan", Payload: map[string]interface{}{
|
||||
"context_policy": pubsdk.ContextPolicyNone,
|
||||
}}
|
||||
if a.pruneDeclared(evt) {
|
||||
t.Fatal("注入点声明 none 应覆盖通道的 prune")
|
||||
}
|
||||
|
||||
// 通道没说,注入点说 prune → 裁。
|
||||
m.RegisterInputChannel("plain", pubsdk.ChannelDef{})
|
||||
evt = &agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{
|
||||
"context_policy": pubsdk.ContextPolicyPrune,
|
||||
}}
|
||||
if !a.pruneDeclared(evt) {
|
||||
t.Fatal("注入点声明 prune 应生效")
|
||||
}
|
||||
}
|
||||
|
||||
// 没有 context 时不能 panic,也不该裁剪。
|
||||
func TestPruneOnInput_NilContextIsSafe(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
m.RegisterInputChannel("noisy", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
|
||||
a := &Agent{io: m}
|
||||
if got := a.pruneOnInput(&agentIO.InputEvent{Source: "noisy", Payload: map[string]interface{}{}}, "x"); got != 0 {
|
||||
t.Fatalf("nil context 应返回 0,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanInputFor 的优先级:注入点声明的 cleaner > 按 source 查的 cleaner > 原文。
|
||||
func TestCleanInputFor_Priority(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
m.RegisterInputChannel("src", pubsdk.ChannelDef{
|
||||
Cleaner: func(s string) string { return "by-source:" + s },
|
||||
})
|
||||
m.RegisterInputChannel("explicit", pubsdk.ChannelDef{
|
||||
Cleaner: func(s string) string { return "by-name:" + s },
|
||||
})
|
||||
a := &Agent{io: m}
|
||||
|
||||
// 无声明 → 用 source 的 cleaner
|
||||
evt := &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{}}
|
||||
if got := a.cleanInputFor(evt, "raw"); got != "by-source:raw" {
|
||||
t.Fatalf("应回退到 source 的 cleaner,实际 %q", got)
|
||||
}
|
||||
|
||||
// 注入点指定 cleaner_name → 覆盖 source 的
|
||||
evt = &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{"cleaner_name": "explicit"}}
|
||||
if got := a.cleanInputFor(evt, "raw"); got != "by-name:raw" {
|
||||
t.Fatalf("注入点声明的 cleaner 应优先,实际 %q", got)
|
||||
}
|
||||
|
||||
// 完全没有 cleaner → 原文
|
||||
evt = &agentIO.InputEvent{Source: "nobody", Payload: map[string]interface{}{}}
|
||||
if got := a.cleanInputFor(evt, "raw"); got != "raw" {
|
||||
t.Fatalf("没有 cleaner 时应返回原文,实际 %q", got)
|
||||
}
|
||||
|
||||
// 声明的名字查不到 → 回退到 source 的 cleaner(并记日志),不能 panic、不能丢内容
|
||||
evt = &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{"cleaner_name": "missing"}}
|
||||
if got := a.cleanInputFor(evt, "raw"); got != "by-source:raw" {
|
||||
t.Fatalf("未知 cleaner_name 应回退,实际 %q", got)
|
||||
}
|
||||
|
||||
// nil IOManager 不能 panic
|
||||
if got := (&Agent{}).cleanInputFor(evt, "raw"); got != "raw" {
|
||||
t.Fatalf("nil io 应返回原文,实际 %q", got)
|
||||
}
|
||||
}
|
||||
65
internal/agent/core/prunequery_test.go
Normal file
65
internal/agent/core/prunequery_test.go
Normal file
@ -0,0 +1,65 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// ContextPolicy=prune 的查询向量必须取**清洗后**的有效内容。
|
||||
//
|
||||
// 裁剪的入参是相关性查询向量,它决定保留/归档哪些上下文事件。原始工具输出里
|
||||
// 混着 ANSI 转义、base64、JSON 包装等噪声,直接向量化会让打分失真,裁掉本该
|
||||
// 保留的事件。ToolDef.Cleaner 的契约本就写着「仅在向量化/jieba/蒸馏时调用」,
|
||||
// 裁剪正是在向量化——此前只在构建事件向量时用了它,裁剪查询漏了。
|
||||
func TestToolOutputForQueryAppliesCleaner(t *testing.T) {
|
||||
host := NewStageHost()
|
||||
called := 0
|
||||
if err := host.RegisterTool("demo_tool", sdk.ToolDef{
|
||||
Name: "demo_tool",
|
||||
Cleaner: func(s string) string {
|
||||
called++
|
||||
return "cleaned:" + s
|
||||
},
|
||||
}, func(map[string]interface{}) (interface{}, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
|
||||
a := &Agent{stageHost: host}
|
||||
raw := "\x1b[31mresult\x1b[0m"
|
||||
|
||||
got := a.toolOutputForQuery("demo_tool", raw)
|
||||
if called != 1 {
|
||||
t.Fatalf("Cleaner 应被调用恰好一次,实际 %d", called)
|
||||
}
|
||||
if got != "cleaned:"+raw {
|
||||
t.Fatalf("查询应使用清洗结果,实际 %q", got)
|
||||
}
|
||||
|
||||
// 未注册 Cleaner 的工具:回退原文。
|
||||
if got := a.toolOutputForQuery("no_such_tool", raw); got != raw {
|
||||
t.Fatalf("无 Cleaner 应回退原文,实际 %q", got)
|
||||
}
|
||||
|
||||
// 无 StageHost(如裸 Agent):不能 panic,回退原文。
|
||||
if got := (&Agent{}).toolOutputForQuery("demo_tool", raw); got != raw {
|
||||
t.Fatalf("nil stageHost 应回退原文,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleaner 返回空串时必须回退原文:空串会让查询向量退化成零向量,
|
||||
// 所有事件相关性相同,裁剪就失去判据(等于随机裁)。
|
||||
func TestToolOutputForQueryEmptyCleanFallsBack(t *testing.T) {
|
||||
host := NewStageHost()
|
||||
if err := host.RegisterTool("t", sdk.ToolDef{
|
||||
Name: "t",
|
||||
Cleaner: func(string) string { return "" },
|
||||
}, func(map[string]interface{}) (interface{}, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
|
||||
a := &Agent{stageHost: host}
|
||||
if got := a.toolOutputForQuery("t", "raw"); got != "raw" {
|
||||
t.Fatalf("Cleaner 返回空应回退原文,实际 %q", got)
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,42 @@ import (
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// childTaskState 是一个子任务的生命周期状态。
|
||||
//
|
||||
// delivered 代替了早期的“读到即删”:完成通知会写进持久上下文
|
||||
// (formatMergedTimeline 每轮重新注入),模型之后还会再查。读一次就删的
|
||||
// 话,第二次查询返回“不存在或已过期”——那是一个**永远不会成功的可操作
|
||||
// 信号**,模型只能一遍遍地重试/汇报,循环永不结束。
|
||||
type childTaskState struct {
|
||||
running bool
|
||||
result string
|
||||
delivered bool // 结果是否已交付过(用于幂等应答)
|
||||
seq int64 // 完成顺序,用于有界淘汰
|
||||
}
|
||||
|
||||
// maxRetainedChildTasks 是保留的已完成子任务上限(防结果无限占用内存)。
|
||||
const maxRetainedChildTasks = 20
|
||||
|
||||
// evictChildTasksLocked 淘汰最旧的已完成子任务。调用方必须持有 childMu。
|
||||
func (a *Agent) evictChildTasksLocked() {
|
||||
for len(a.childTasks) > maxRetainedChildTasks {
|
||||
oldestID := ""
|
||||
var oldestSeq int64
|
||||
for id, st := range a.childTasks {
|
||||
if st.running {
|
||||
continue
|
||||
}
|
||||
if oldestID == "" || st.seq < oldestSeq {
|
||||
oldestID, oldestSeq = id, st.seq
|
||||
}
|
||||
}
|
||||
if oldestID == "" {
|
||||
return // 剩下全是运行中的,不淘汰
|
||||
}
|
||||
delete(a.childTasks, oldestID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
||||
task, _ := tc.Arguments["task"].(string)
|
||||
if task == "" {
|
||||
@ -41,11 +77,11 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
|
||||
a.childMu.Lock()
|
||||
a.childRunning[taskID] = true
|
||||
a.childTasks[taskID] = &childTaskState{running: true}
|
||||
a.childMu.Unlock()
|
||||
go a.runChildTask(taskID, task, parentChannel, maxTurns)
|
||||
|
||||
return fmt.Sprintf("子任务已启动(ID: %s,最多 %d 轮),完成后会自动通知你,届时请使用 child_result 工具查看输出", taskID, maxTurns)
|
||||
return fmt.Sprintf("子任务已启动(ID: %s,最多 %d 轮)。完成后会自动通知你,届时用 child_result 查看输出即可(**只需查询一次**)", taskID, maxTurns)
|
||||
}
|
||||
|
||||
// defaultChildMaxTurns 子 Agent 默认工具轮数(可被 spawn_child 的 max_turns 参数覆盖)。
|
||||
@ -126,19 +162,30 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns
|
||||
}
|
||||
|
||||
a.childMu.Lock()
|
||||
a.childResults[taskID] = finalResult
|
||||
delete(a.childRunning, taskID)
|
||||
if st := a.childTasks[taskID]; st != nil {
|
||||
st.running = false
|
||||
st.result = finalResult
|
||||
a.childSeq++
|
||||
st.seq = a.childSeq
|
||||
}
|
||||
a.evictChildTasksLocked()
|
||||
a.childMu.Unlock()
|
||||
|
||||
log.Printf("[child] %s done: %s", taskID, truncateStr(finalResult, 100))
|
||||
|
||||
notification := fmt.Sprintf("子任务 %s 已完成,请调用 child_result 工具查看输出", taskID)
|
||||
notification := fmt.Sprintf("子任务 %s 已完成。请用 child_result 工具查看输出(只需查询一次;重复查询不会返回失败)。", taskID)
|
||||
a.injectSelfChannel(selfInputMsg{
|
||||
text: notification,
|
||||
channel: parentChannel, // 回到父对话通道,正常处理(写入上下文 + emit 响应)
|
||||
})
|
||||
}
|
||||
|
||||
// executeChildResultTool 取回子任务结果。
|
||||
//
|
||||
// **幂等**:结果不会被“读到即删”,重复查询返回同一结果或一条明确提示。
|
||||
// 这一点至关重要——完成通知会长期留在持久上下文里(formatMergedTimeline
|
||||
// 每轮重新注入),如果重复查询返回“不存在”这种失败信号,模型会认定任务
|
||||
// 未完成而无限重试(实测单轮 35 次工具调用、持续 514 秒)。
|
||||
func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string {
|
||||
taskID, _ := tc.Arguments["task_id"].(string)
|
||||
if taskID == "" {
|
||||
@ -146,18 +193,25 @@ func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
|
||||
a.childMu.Lock()
|
||||
result, ok := a.childResults[taskID]
|
||||
if ok {
|
||||
delete(a.childResults, taskID)
|
||||
st, ok := a.childTasks[taskID]
|
||||
if !ok {
|
||||
a.childMu.Unlock()
|
||||
return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result)
|
||||
return fmt.Sprintf("子任务 %s 不存在:从未创建该 ID(请核对 spawn_child 返回的 ID 拼写)", taskID)
|
||||
}
|
||||
if a.childRunning[taskID] {
|
||||
if st.running {
|
||||
a.childMu.Unlock()
|
||||
return fmt.Sprintf("子任务 %s 仍在运行中,尚未完成。请等待完成通知后再查询。", taskID)
|
||||
}
|
||||
first := !st.delivered
|
||||
st.delivered = true
|
||||
result := st.result
|
||||
a.childMu.Unlock()
|
||||
return fmt.Sprintf("子任务 %s 不存在或已过期", taskID)
|
||||
|
||||
if first {
|
||||
return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result)
|
||||
}
|
||||
// 重复查询不是失败:明确告诉模型“任务已完成、结果已给过”,让它停止重试。
|
||||
return fmt.Sprintf("【子任务 %s 已完成】结果已在上文提供(见先前的 child_result 工具结果),无需重复查询;请直接基于上文结果继续。", taskID)
|
||||
}
|
||||
|
||||
func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
|
||||
|
||||
88
internal/agent/core/spawn_test.go
Normal file
88
internal/agent/core/spawn_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// child_result 必须幂等——这是 "任务已结束但核心循环不结束" 的根因修复。
|
||||
//
|
||||
// 子任务完成通知会写进持久上下文(formatMergedTimeline 每轮重新注入),
|
||||
// 模型之后还会再查。若第二次查询返回 "不存在或已过期" 这种**永久失败信号**,
|
||||
// 模型会认定任务未完成而无限重试/汇报(生产实测:单轮 35 次工具调用、
|
||||
// 持续 514 秒)。
|
||||
func TestChildResultIsIdempotent(t *testing.T) {
|
||||
a := New(AgentConfig{ID: "t"})
|
||||
|
||||
a.childMu.Lock()
|
||||
a.childTasks["child_1"] = &childTaskState{result: "任务完成:已创建 3 个日程", seq: 1}
|
||||
a.childMu.Unlock()
|
||||
|
||||
call := func(id string) string {
|
||||
return a.executeChildResultTool(agentAPI.ToolCall{
|
||||
Name: "child_result",
|
||||
Arguments: map[string]interface{}{"task_id": id},
|
||||
})
|
||||
}
|
||||
|
||||
first := call("child_1")
|
||||
if !strings.Contains(first, "任务完成:已创建 3 个日程") {
|
||||
t.Fatalf("首次查询应返回结果,实际: %q", first)
|
||||
}
|
||||
|
||||
second := call("child_1")
|
||||
if strings.Contains(second, "不存在") {
|
||||
t.Fatalf("重复查询不能返回失败信号(会驱动模型无限重试),实际: %q", second)
|
||||
}
|
||||
if !strings.Contains(second, "已完成") {
|
||||
t.Fatalf("重复查询应明确告知「已完成、结果已提供」,实际: %q", second)
|
||||
}
|
||||
|
||||
// 只有从未创建过的 ID 才应报 "不存在"。
|
||||
missing := call("child_999")
|
||||
if !strings.Contains(missing, "不存在") {
|
||||
t.Fatalf("未知 ID 应报不存在,实际: %q", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行中与已完成必须给出不同答复,否则模型无法判断该等还是该继续。
|
||||
func TestChildResultRunningVsDone(t *testing.T) {
|
||||
a := New(AgentConfig{ID: "t"})
|
||||
|
||||
a.childMu.Lock()
|
||||
a.childTasks["child_run"] = &childTaskState{running: true}
|
||||
a.childMu.Unlock()
|
||||
|
||||
got := a.executeChildResultTool(agentAPI.ToolCall{
|
||||
Name: "child_result",
|
||||
Arguments: map[string]interface{}{"task_id": "child_run"},
|
||||
})
|
||||
if !strings.Contains(got, "仍在运行中") {
|
||||
t.Fatalf("运行中的任务应提示仍在运行,实际: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 保留的结果必须有界,不能随子任务数量无限增长。
|
||||
func TestChildTaskRetentionBounded(t *testing.T) {
|
||||
a := New(AgentConfig{ID: "t"})
|
||||
|
||||
a.childMu.Lock()
|
||||
for i := 0; i < maxRetainedChildTasks*3; i++ {
|
||||
a.childSeq++
|
||||
a.childTasks[fmt.Sprintf("child_%d", i)] = &childTaskState{result: "r", seq: a.childSeq}
|
||||
}
|
||||
a.evictChildTasksLocked()
|
||||
n := len(a.childTasks)
|
||||
a.childMu.Unlock()
|
||||
|
||||
if n > maxRetainedChildTasks {
|
||||
t.Fatalf("保留子任务数=%d,超过上限 %d", n, maxRetainedChildTasks)
|
||||
}
|
||||
// 淘汰应保留最新的:最早的那批必须已不在
|
||||
if _, ok := a.childTasks["child_0"]; ok {
|
||||
t.Fatal("淘汰应优先丢弃最旧的已完成任务")
|
||||
}
|
||||
}
|
||||
@ -73,6 +73,8 @@ func collectKernelStatus(
|
||||
BuildTime: meta.BuildTime,
|
||||
SDKCompatible: meta.SDKCompatibleVersion,
|
||||
KernelName: meta.KernelName,
|
||||
// AGPL-3.0 §13:状态页向网络使用者展示取得源码的入口。
|
||||
SourceURL: meta.SourceURL,
|
||||
},
|
||||
Runtime: RuntimeStatus{
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
@ -196,6 +198,14 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
|
||||
trk = a.tracker
|
||||
}
|
||||
|
||||
// 注意:knowledge 在 collectKernelStatus 里是**接口**参数,
|
||||
// 而 (*knowledge.Store)(nil) 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。
|
||||
// 所以这里必须先判具体指针再进行接口赋值(healthcheck_kernel 会走到这条路径)。
|
||||
var knowledgeLister interface{ List() []string }
|
||||
if a.knowledge != nil {
|
||||
knowledgeLister = a.knowledge
|
||||
}
|
||||
|
||||
ks := collectKernelStatus(
|
||||
a.startTime,
|
||||
string(a.id),
|
||||
@ -205,15 +215,43 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
|
||||
a.io,
|
||||
a.pluginReg,
|
||||
a.memory,
|
||||
a.knowledge,
|
||||
knowledgeLister,
|
||||
a.docStore,
|
||||
textMem,
|
||||
socialStore,
|
||||
trk,
|
||||
)
|
||||
ks.ONNX = a.onnxStatus()
|
||||
|
||||
return ks
|
||||
}
|
||||
|
||||
// onnxStatus 汇总统一多模态向量空间(ONNX 模型)的启用状态。
|
||||
//
|
||||
// 判据是 Loaded()(provider 真正打开且元数据合法),**不是**「配置里写了 provider」——
|
||||
// 后者在模型缺失 / 运行时缺失时也为真,拿它当判据就是假绿。
|
||||
func (a *Agent) onnxStatus() sdk.ONNXStatus {
|
||||
st := sdk.ONNXStatus{Provider: a.embeddingProvider}
|
||||
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() {
|
||||
st.Enabled = true
|
||||
st.Dim = a.multimodalSpace.Dim()
|
||||
st.Fingerprint = a.multimodalSpace.Fingerprint()
|
||||
// 模态是可选能力:只有底层 provider 报出来时才带出。
|
||||
if mr, ok := a.multimodalSpace.(interface{ Modalities() []string }); ok {
|
||||
st.Modalities = mr.Modalities()
|
||||
}
|
||||
return st
|
||||
}
|
||||
switch {
|
||||
case a.embeddingError != "":
|
||||
st.Reason = "打开失败: " + a.embeddingError
|
||||
case a.embeddingProvider == "":
|
||||
st.Reason = "未配置统一向量空间 provider(走词嵌入/TF-IDF 回退路径)"
|
||||
default:
|
||||
st.Reason = "provider 未加载"
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
var _ StatusProvider = (*Agent)(nil)
|
||||
var _ sdk.StatusAPI = (*Agent)(nil)
|
||||
|
||||
103
internal/agent/core/status_onnx_test.go
Normal file
103
internal/agent/core/status_onnx_test.go
Normal file
@ -0,0 +1,103 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
)
|
||||
|
||||
// statusSpace 是带模态元数据的假统一空间(ProviderAdapter 的 Modalities() 形状)。
|
||||
type statusSpace struct{ fakeSpace }
|
||||
|
||||
func (statusSpace) Modalities() []string { return []string{"text", "image"} }
|
||||
|
||||
// statusSpaceUnloaded 模拟「provider 建了但没加载成功」。
|
||||
type statusSpaceUnloaded struct{ fakeSpace }
|
||||
|
||||
func (statusSpaceUnloaded) Loaded() bool { return false }
|
||||
|
||||
// healthcheck_kernel 必须能回答两件事:内核版本号、以及**是否真的启用了 ONNX 模型**。
|
||||
//
|
||||
// 判据要点:只有 provider 真正 Loaded() 才算启用 ——「配置里写了 provider」不算,
|
||||
// 否则模型缺失/运行时缺失时会报成已启用(假绿)。
|
||||
func TestKernelStatusReportsVersionAndONNX(t *testing.T) {
|
||||
t.Run("已启用:带出 provider/维度/指纹/模态", func(t *testing.T) {
|
||||
a := &Agent{
|
||||
io: agentIO.NewIOManager(),
|
||||
multimodalSpace: statusSpace{},
|
||||
embeddingProvider: "chineseclip",
|
||||
}
|
||||
st := a.GetKernelStatus()
|
||||
if !st.ONNX.Enabled {
|
||||
t.Fatal("Loaded() 为真时 onnx.enabled 必须为真")
|
||||
}
|
||||
if st.ONNX.Provider != "chineseclip" || st.ONNX.Dim != 2 || st.ONNX.Fingerprint != "fake-space" {
|
||||
t.Fatalf("onnx 身份字段不对: %+v", st.ONNX)
|
||||
}
|
||||
if len(st.ONNX.Modalities) != 2 || st.ONNX.Reason != "" {
|
||||
t.Fatalf("模态/原因不对: %+v", st.ONNX)
|
||||
}
|
||||
// 内核版本号必须随状态一起报:人格卡要求「版本以运行时快照为准」靠的就是这一项
|
||||
if st.Build.Version == "" || st.Build.KernelName == "" {
|
||||
t.Fatalf("build 段缺少版本/内核名: %+v", st.Build)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("打开失败:enabled=false 且给出具体原因", func(t *testing.T) {
|
||||
a := &Agent{
|
||||
io: agentIO.NewIOManager(),
|
||||
embeddingProvider: "chineseclip",
|
||||
embeddingError: `embedding: open provider "chineseclip": model dir missing`,
|
||||
}
|
||||
st := a.GetKernelStatus()
|
||||
if st.ONNX.Enabled {
|
||||
t.Fatal("打开失败时不能报 enabled")
|
||||
}
|
||||
if st.ONNX.Provider != "chineseclip" {
|
||||
t.Fatalf("未启用时仍应带出配置的 provider: %+v", st.ONNX)
|
||||
}
|
||||
if !strings.Contains(st.ONNX.Reason, "model dir missing") {
|
||||
t.Fatalf("原因应包含具体错误: %q", st.ONNX.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未配置:说明会走回退路径", func(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
st := a.GetKernelStatus()
|
||||
if st.ONNX.Enabled || st.ONNX.Reason == "" {
|
||||
t.Fatalf("未配置时应 enabled=false 且有原因: %+v", st.ONNX)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("provider 存在但未加载", func(t *testing.T) {
|
||||
a := &Agent{
|
||||
io: agentIO.NewIOManager(),
|
||||
multimodalSpace: statusSpaceUnloaded{},
|
||||
embeddingProvider: "qwen3vl",
|
||||
}
|
||||
st := a.GetKernelStatus()
|
||||
if st.ONNX.Enabled {
|
||||
t.Fatal("Loaded() 为假时不能报 enabled")
|
||||
}
|
||||
if st.ONNX.Reason == "" {
|
||||
t.Fatalf("应给出未加载的原因: %+v", st.ONNX)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// collectKernelStatus 的 knowledge 参数是**接口**类型,而 (*knowledge.Store)(nil)
|
||||
// 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。
|
||||
// 这条测试钉住这个成因:一旦不再 panic,说明参数形状变了,
|
||||
// GetKernelStatus 里的 typed-nil 守卫就该同步删掉(否则它变成无意义代码)。
|
||||
func TestCollectKernelStatusTypedNilKnowledgePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("typed-nil 交给接口参数却未 panic:成因已变,请更新守卫与本测试")
|
||||
}
|
||||
}()
|
||||
var nilStore *knowledge.Store
|
||||
_ = collectKernelStatus(time.Now(), "a", "", 0, nil, nil, nil, nil, nilStore, nil, nil, nil, nil)
|
||||
}
|
||||
@ -45,6 +45,8 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
|
||||
|
||||
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
|
||||
switch {
|
||||
case tc.Name == "persona_set":
|
||||
return a.executePersonaTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "memory_"):
|
||||
return a.executeMemoryTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "social_"):
|
||||
@ -150,11 +152,11 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName))
|
||||
}
|
||||
// 命中的关系若挂着媒体,把媒体说明附在结果末尾。
|
||||
// 命中的关系若挂着媒体块,把媒体说明附在结果末尾。
|
||||
//
|
||||
// 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。
|
||||
// 媒体挂在句子上(graph_sentence owner),需经关系→句子→media_refs
|
||||
// 反查。不附上的后果:agent 显式查了图记忆,却仍然不知道有图。
|
||||
// 媒体块以结构边与句子相连,需经关系→句子反查。
|
||||
// 不附上的后果:agent 显式查了图记忆,却仍然不知道有图。
|
||||
if mc := a.mediaContextForRelations(result.Relations); mc != "" {
|
||||
parts = append(parts, "", "关联媒体:", mc)
|
||||
}
|
||||
@ -190,11 +192,16 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
Object: getString(m, "object"),
|
||||
SentenceText: getString(m, "sentence_text"),
|
||||
}
|
||||
// 模型显式关联的媒体:标记由内核补进句子文本,模型不必知道格式。
|
||||
// 没有 sentence_text 时 sentenceWithMediaMarkers 会用标记本身
|
||||
// 充当句子——媒体必须有句子落点,否则 media_refs 无从挂起。
|
||||
// 模型显式关联的媒体:结构化字段随三元组一起提交,
|
||||
// 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边——
|
||||
// 不再把 marker 写进句子文本。
|
||||
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
|
||||
t.SentenceText = a.sentenceWithMediaMarkers(t.SentenceText, digests)
|
||||
t.MediaDigests = a.resolveMediaDigests(digests)
|
||||
// 块边需要句子作端点。模型没给原句时用三元组本身拼一句
|
||||
// 自然语言——不能造一段 marker 文本,那正是被废弃的东西。
|
||||
if t.SentenceText == "" && len(t.MediaDigests) > 0 {
|
||||
t.SentenceText = fmt.Sprintf("%s%s%s。", t.Subject, t.Relation, t.Object)
|
||||
}
|
||||
}
|
||||
if t.Subject != "" && t.Relation != "" && t.Object != "" {
|
||||
triples = append(triples, t)
|
||||
@ -206,7 +213,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
// remember 工具是用户/模型显式写入,不涉及归档删除,
|
||||
// 因此不需要 mediaBound——没有旧引用要释放。
|
||||
ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0)
|
||||
ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0, nil)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("记忆写入失败: %v", err)
|
||||
}
|
||||
@ -531,10 +538,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
if len(content) > 2000 {
|
||||
content = content[:2000] + "..."
|
||||
}
|
||||
// 媒体说明单独一行进冷存事件:正文可能被上面的 2000 字截断,
|
||||
// 而媒体标记往往在文档末尾——截掉之后模型就不知道这篇文档带过图。
|
||||
if mc := a.docMediaContext(d.ID, d.Content); mc != "" {
|
||||
content = content + "\n关联媒体: " + mc
|
||||
// 媒体块标签单独一行进冷存事件:正文可能被上面的 2000 字截断,
|
||||
// 截掉之后模型就不知道这篇文档带过图。
|
||||
if labels := a.blockLabelsForDoc(d); labels != "" {
|
||||
content = content + "\n关联媒体: " + labels
|
||||
}
|
||||
a.context.InsertByTimestamp(ContextEvent{
|
||||
Timestamp: d.CreatedAt,
|
||||
@ -572,19 +579,20 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
Source: "manual",
|
||||
}
|
||||
|
||||
// 模型显式关联的媒体:标记补进正文后再写入。顺序关键——向量索引用
|
||||
// Summary+Content 计算,标记进不去正文就检索不到这份媒体。
|
||||
mediaDigests := a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests"))
|
||||
doc.Content = a.sentenceWithMediaMarkers(doc.Content, mediaDigests)
|
||||
// 模型显式关联的媒体:直接变成文档持有的一等块。
|
||||
// 不再往正文写 marker——文档向量会融合这些块的媒体向量,
|
||||
// 图片按自己的向量被检索。
|
||||
for _, d := range a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) {
|
||||
if b, ok := a.blockFromDigest(d); ok {
|
||||
doc.Blocks = append(doc.Blocks, b)
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.docStore.Insert(doc); err != nil {
|
||||
return fmt.Sprintf("文档写入失败: %v", err)
|
||||
}
|
||||
// 引用必须在拿到 doc.ID 之后挂:owner_id 就是文档 id。
|
||||
// 不挂的后果是这些媒体在文档里可见却无主,下一轮 GC 会把它们清掉。
|
||||
bound := a.bindDocMedia(doc.ID, mediaDigests)
|
||||
if bound > 0 {
|
||||
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, bound)
|
||||
if n := len(doc.Blocks); n > 0 {
|
||||
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, n)
|
||||
}
|
||||
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary)
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
|
||||
// 不做这一步的后果:媒体描述进了 L3,agent 却拿不出来。图库句子里
|
||||
// 写着 [image/png a1b2c3d4e5f6] 这样的短标记,但没有任何东西告诉
|
||||
// 模型那份内容是否还在、能否重新查看——描述永存而 blob 可能已被
|
||||
// 容量 GC 淘汰,两者状态不同,必须显式告知。
|
||||
// 删除,两者状态不同,必须显式告知。
|
||||
//
|
||||
// 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil
|
||||
//(自动注入只给实体索引以省 token,细节留给 memory_recall)。
|
||||
@ -55,15 +55,13 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
|
||||
prompt += "\n\n【记忆清理指令】当用户要求整理或清理记忆时,你必须实际调用 memory_ 工具执行操作,不能只回复文本。先用 memory_introspect 查看概况,再用 memory_recall 获取详情。有同义实体则用 memory_merge 合并(source 会被彻底删除),有无用噪音实体则用 memory_delete_entity 直接删除,也可用 memory_purge 批量清理,用 memory_edit 修正错误,用 memory_block_merge 标记不合并。如果工具执行成功,把结果告知用户;不要只描述计划而不执行。"
|
||||
|
||||
// 跨模态召回:文本路(fastText/TF-IDF 文档层,媒体描述文本已随记忆进入)
|
||||
// + 视觉路(多模态文本编码 → 媒体库坐标)两路归一化融合。
|
||||
// 未配置多模态空间时视觉路为空,等价旧的 docStore.Query。
|
||||
if a.docStore != nil {
|
||||
docs := a.docStore.Query(userInput, 3)
|
||||
if len(docs) > 0 {
|
||||
var parts []string
|
||||
parts = append(parts, "【相关记忆文档】")
|
||||
for i, d := range docs {
|
||||
parts = append(parts, fmt.Sprintf(" [%d] %s", i+1, d.Summary))
|
||||
}
|
||||
prompt += "\n\n" + strings.Join(parts, "\n")
|
||||
hits := a.retrieveCrossModal(userInput, 3, a.fusionCfg)
|
||||
if md := a.crossModalMarkdown(hits); md != "" {
|
||||
prompt += "\n\n" + md
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,7 +73,7 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n"
|
||||
prompt += "- 异步通道(qq / wechat / 群聊等):返回纯文本**【不会】**自动送达用户,必须调用 output_send__{通道名} 工具(注意 meta 里带上正确的 user_id 或 group_id)才能真正把消息发出去。\n"
|
||||
prompt += "- 不确定当前通道的发送方式时,先用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举,再决定。\n"
|
||||
prompt += "- 同一轮对话中可多次调用输出门工具。长消息应当分多次发出,而不是一口气发完。\n"
|
||||
prompt += "- 每轮对话**通常只需调用一次** output_send__{通道名} 即可完成回复。仅在内容确实超过单条消息长度上限(如 >4000 字)时才拆分为多条;拆分时每条应是完整段落,不要碎片化。\n"
|
||||
prompt += "- 需要多步执行的长任务:**必须先**向当前对话通道发一条确认消息告诉用户已收到(异步通道用输出门工具,同步通道直接返回文本),**然后再**执行具体排查工具。确认消息不代表任务完成,发出后仍需继续执行实际工具并最终汇报结果。\n"
|
||||
prompt += "- 用户从其他渠道发来「在哪里/怎么样了」这类追问时,先回忆上次任务的通道与上下文,再回同一通道。"
|
||||
|
||||
@ -91,6 +89,16 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 首启人格门禁(跨通道唯一闸口):人格未确认时,要求模型主动询问用户。
|
||||
// 系统提示词每轮重建,因此 WebUI / QQ / CLI / ACP / 邮件等所有通道都会带上它;
|
||||
// 模型调用 persona_set(或用户在 WebUI 向导里选)落地后,标记置位,本段消失。
|
||||
if a.personaStore != nil && !a.personaStore.PersonaInitialized() {
|
||||
prompt += "\n\n【首启人格设定】你的**人格设定尚未确认**。请在本轮回复里先问用户一句:" +
|
||||
"要用默认人格,还是自定义一个?拿到明确答复后**必须调用 persona_set 工具**落库:" +
|
||||
"用户选默认 → mode=default;自定义 → mode=custom 且把内容写进 content;" +
|
||||
"用户说以后再说 → mode=later。用户答复前不要假设已设置,也不要反复追问同一件事。"
|
||||
}
|
||||
|
||||
prompt += a.buildToolCatalog()
|
||||
|
||||
return prompt
|
||||
@ -199,6 +207,24 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
if a.personaStore != nil {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "persona_set",
|
||||
"description": "【首启人格】落地用户的人格选择并记录「已经问过」。仅在用户明确答复后调用:默认用 mode=default;自定义用 mode=custom 并把人格内容放进 content;用户说以后再说用 mode=later。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"mode": map[string]interface{}{"type": "string", "description": "default | custom | later"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "自定义人格内容(mode=custom 时必填)"},
|
||||
},
|
||||
"required": []string{"mode"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if a.memory != nil {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
|
||||
117
internal/agent/core/tooloop_test.go
Normal file
117
internal/agent/core/tooloop_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// appendPlaceholder 复刻 process() 循环顶部的补位逻辑。
|
||||
func appendPlaceholder(msgs []agentAPI.Message, replyOnly bool) []agentAPI.Message {
|
||||
msgs = dropContinuationPlaceholders(msgs)
|
||||
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "user", Content: continuationFor(replyOnly)})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func countPlaceholders(msgs []agentAPI.Message) int {
|
||||
n := 0
|
||||
for _, m := range msgs {
|
||||
if isContinuationPlaceholder(m) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 占位是核心插入的传输层附加物,不是用户发言——它不能随轮次线性累积。
|
||||
//
|
||||
// 旧实现每轮无条件追加而从不移除,跑 N 轮 prompt 里就叠了 N 条一模一样的
|
||||
// “继续”,把前缀上下文(含记忆注入)往后挤。
|
||||
func TestPlaceholderDoesNotAccumulate(t *testing.T) {
|
||||
msgs := []agentAPI.Message{{Role: "user", Content: "用户请求"}}
|
||||
|
||||
const rounds = 20
|
||||
for turn := 0; turn < rounds; turn++ {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: "调用工具"})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", Content: "结果"})
|
||||
|
||||
// 交替普通工具轮 / 纯发送轮,确保两种文案都参与去重。
|
||||
msgs = appendPlaceholder(msgs, turn%2 == 1)
|
||||
|
||||
if n := countPlaceholders(msgs); n != 1 {
|
||||
t.Fatalf("第 %d 轮后占位数=%d,期望恰好 1 条(旧实现会累积到 %d 条)", turn+1, n, turn+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 末尾那一轮是纯发送轮,留下的应是“允许收尾”的文案。
|
||||
if last := msgs[len(msgs)-1]; last.Content != replyDeliveredPlaceholder {
|
||||
t.Fatalf("最后应是回复已交付的文案,实际: %q", last.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// 首轮 system/真实用户输入结尾不补位:补了会覆盖实际用户输入。
|
||||
func TestPlaceholderNotAppendedOnFirstTurn(t *testing.T) {
|
||||
msgs := []agentAPI.Message{
|
||||
{Role: "system", Content: "系统说明"},
|
||||
{Role: "user", Content: "真实用户输入"},
|
||||
}
|
||||
got := appendPlaceholder(msgs, false)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("首轮不应补位,得到 %d 条: %+v", len(got), got)
|
||||
}
|
||||
if got[1].Content != "真实用户输入" {
|
||||
t.Fatalf("真实用户输入被覆盖: %q", got[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
// 内容相近的真实用户消息不能被当作占位删掉。
|
||||
func TestDropOnlyExactPlaceholder(t *testing.T) {
|
||||
msgs := []agentAPI.Message{
|
||||
{Role: "user", Content: continuationPlaceholder + "补充"},
|
||||
{Role: "user", Content: replyDeliveredPlaceholder + "补充"},
|
||||
{Role: "user", Content: continuationPlaceholder},
|
||||
}
|
||||
got := dropContinuationPlaceholders(msgs)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("只应删掉精确匹配的那条,得到 %d 条: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// 纯输出通道调用之后的补位不能再是「请继续」。
|
||||
//
|
||||
// 异步通道的回复只能经 output_send__* 交付,所以模型「已完成回复」的形式就是
|
||||
// 一个工具调用;紧跟一句「请继续」会被读成「还要再做一步」,而能做的
|
||||
// 「一步」恰好还是再发一条消息。(生产实测:单轮 34 次发送、514 秒)
|
||||
func TestContinuationForReplyDoesNotPushToContinue(t *testing.T) {
|
||||
reply := continuationFor(true)
|
||||
if reply == continuationPlaceholder {
|
||||
t.Fatal("回复已交付后不应再补「请继续」,会驱动重复发送")
|
||||
}
|
||||
if !strings.Contains(reply, "纯文本") || !strings.Contains(reply, "结束") {
|
||||
t.Fatalf("应明确告知可返回纯文本收尾,实际: %q", reply)
|
||||
}
|
||||
|
||||
if got := continuationFor(false); got != continuationPlaceholder {
|
||||
t.Fatalf("普通工具轮补位应保持不变,实际: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有真正的发送动作算「交付回复」;_help 是查询用法。
|
||||
func TestIsOutputDeliveryTool(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"output_send__qq": true,
|
||||
"output_send__webui": true,
|
||||
"output_send__qq_help": false,
|
||||
"output_list_channels": false,
|
||||
"cmd_run": false,
|
||||
"qq_get_message": false,
|
||||
}
|
||||
for name, want := range cases {
|
||||
if got := isOutputDeliveryTool(name); got != want {
|
||||
t.Errorf("isOutputDeliveryTool(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,11 +25,11 @@ const (
|
||||
type OutputCapability int
|
||||
|
||||
const (
|
||||
CapText OutputCapability = 1 << iota // 文本
|
||||
CapFile // 文件
|
||||
CapImage // 图片
|
||||
CapAudio // 音频
|
||||
CapStructured // 结构化数据(JSON/卡片)
|
||||
CapText OutputCapability = 1 << iota // 文本
|
||||
CapFile // 文件
|
||||
CapImage // 图片
|
||||
CapAudio // 音频
|
||||
CapStructured // 结构化数据(JSON/卡片)
|
||||
)
|
||||
|
||||
func (c OutputCapability) Supports(cap OutputCapability) bool {
|
||||
@ -242,6 +242,52 @@ func (m *IOManager) InjectInputSyncTo(source, outputChannel, eventType string, p
|
||||
return <-ch
|
||||
}
|
||||
|
||||
// InjectOptions 声明一次注入在记忆层与上下文层的表现。
|
||||
//
|
||||
// 零值 = 记入记忆 + 不裁剪上下文,与历史的三参数注入方法完全一致。
|
||||
// 别名到公共 SDK 而非另建一套:内置插件与外部插件必须用同一套结构,
|
||||
// 否则内核要认两种类型,而漏认会静默丢失标志位。
|
||||
type InjectOptions = pubsdk.InjectOptions
|
||||
|
||||
// applyInjectOpts 把注入标志位写进事件 payload。
|
||||
//
|
||||
// 只在非零时写:零值与旧 payload 逐字节一致,事件订阅方与旧内核
|
||||
// (不认识这两个键)都不会受影响。
|
||||
//
|
||||
// 为什么不把标志位当独立参数传到底:eventloop 与各注入路径都按 payload 取字段
|
||||
// (no_memory 本来就是这么走的),payload 是这里唯一已有的携带面。
|
||||
func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
|
||||
if opts.NoMemory {
|
||||
payload["no_memory"] = true
|
||||
}
|
||||
if opts.ContextPolicy != "" {
|
||||
payload["context_policy"] = opts.ContextPolicy
|
||||
}
|
||||
if opts.CleanerName != "" {
|
||||
payload["cleaner_name"] = opts.CleanerName
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInputOpts(source, eventType string, payload map[string]interface{}, opts InjectOptions) {
|
||||
applyInjectOpts(payload, opts)
|
||||
m.InjectInput(source, eventType, payload)
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInputToOpts(source, outputChannel, eventType string, payload map[string]interface{}, opts InjectOptions) {
|
||||
applyInjectOpts(payload, opts)
|
||||
m.InjectInputTo(source, outputChannel, eventType, payload)
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInputSyncToOpts(source, outputChannel, eventType string, payload map[string]interface{}, opts InjectOptions) *OutputEvent {
|
||||
applyInjectOpts(payload, opts)
|
||||
return m.InjectInputSyncTo(source, outputChannel, eventType, payload)
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInterruptOpts(source, channel string, payload map[string]interface{}, opts InjectOptions) {
|
||||
applyInjectOpts(payload, opts)
|
||||
m.InjectInterrupt(source, channel, payload)
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectText(source string, text string) {
|
||||
m.InjectInput(source, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
@ -293,10 +339,31 @@ func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]i
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInterruptText(source, channel, text string) {
|
||||
m.InjectInterrupt(source, channel, map[string]interface{}{
|
||||
m.InjectInterruptTextOpts(source, channel, text, InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectInterruptTextOpts 注入中断文本,并声明本次注入的记忆/裁剪行为。
|
||||
//
|
||||
// 中断也允许声明 ContextPolicyPrune:中断同样携带内容进入上下文。
|
||||
func (m *IOManager) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
|
||||
m.InjectInterruptOpts(source, channel, map[string]interface{}{
|
||||
"type": "text",
|
||||
"content": text,
|
||||
})
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// InjectTextOpts 注入排队文本,并声明本次注入的记忆/裁剪行为。
|
||||
func (m *IOManager) InjectTextOpts(source, channel, text string, opts InjectOptions) {
|
||||
m.InjectInputToOpts(source, channel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// InjectTextSyncOpts 同步注入文本并声明记忆/裁剪行为。
|
||||
func (m *IOManager) InjectTextSyncOpts(source, outputChannel, text string, opts InjectOptions) *OutputEvent {
|
||||
return m.InjectInputSyncToOpts(source, outputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (m *IOManager) InputInterruptChan() <-chan *InputEvent { return m.interruptCh }
|
||||
@ -308,6 +375,33 @@ func (m *IOManager) InjectTextSyncTo(source, outputChannel, text string) *Output
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 带标志位的注入(记忆/裁剪行为由调用点声明)----
|
||||
|
||||
// InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
|
||||
func (m *IOManager) InjectInputMediaOpts(source, outputChannel, text string, blocks []pubsdk.ContentBlock, opts InjectOptions) {
|
||||
m.InjectInputToOpts(source, outputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
|
||||
func (m *IOManager) InjectInputMediaSyncOpts(source, outputChannel, text string, blocks []pubsdk.ContentBlock, opts InjectOptions) *OutputEvent {
|
||||
return m.InjectInputSyncToOpts(source, outputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
|
||||
func (m *IOManager) InjectInterruptMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts InjectOptions) {
|
||||
m.InjectInterruptOpts(source, channel, map[string]interface{}{
|
||||
"type": "text",
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (m *IOManager) EmitOutput(target string, outputType string, payload map[string]interface{}) {
|
||||
m.outputCh <- &OutputEvent{
|
||||
RequestID: "",
|
||||
@ -343,7 +437,7 @@ func (m *IOManager) EmitTextTo(target, outputChannel, text string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
|
||||
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
|
||||
func (m *IOManager) OutputChan() <-chan *OutputEvent { return m.outputCh }
|
||||
|
||||
// RegisterInputChannel 注册输入通道的记忆行为
|
||||
@ -463,12 +557,14 @@ func NewMicrophone(name string, sampleRate int, io *IOManager) *Microphone {
|
||||
return &Microphone{name: name, sampleRate: sampleRate, io: io}
|
||||
}
|
||||
|
||||
func (d *Microphone) Name() string { return d.name }
|
||||
func (d *Microphone) Type() DeviceType { return DeviceInput }
|
||||
func (d *Microphone) Name() string { return d.name }
|
||||
func (d *Microphone) Type() DeviceType { return DeviceInput }
|
||||
func (d *Microphone) OutputCapabilities() OutputCapability { return 0 } // 纯输入
|
||||
func (d *Microphone) Description() string { return fmt.Sprintf("麦克风 (%s, %dHz)", d.name, d.sampleRate) }
|
||||
func (d *Microphone) Start() error { return nil }
|
||||
func (d *Microphone) Stop() error { return nil }
|
||||
func (d *Microphone) Description() string {
|
||||
return fmt.Sprintf("麦克风 (%s, %dHz)", d.name, d.sampleRate)
|
||||
}
|
||||
func (d *Microphone) Start() error { return nil }
|
||||
func (d *Microphone) Stop() error { return nil }
|
||||
func (d *Microphone) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
|
||||
func (d *Microphone) Tools() []ToolDef {
|
||||
@ -498,13 +594,13 @@ func NewSpeaker(name string, io *IOManager) *Speaker {
|
||||
return &Speaker{name: name, io: io}
|
||||
}
|
||||
|
||||
func (d *Speaker) Name() string { return d.name }
|
||||
func (d *Speaker) Type() DeviceType { return DeviceOutput }
|
||||
func (d *Speaker) Name() string { return d.name }
|
||||
func (d *Speaker) Type() DeviceType { return DeviceOutput }
|
||||
func (d *Speaker) OutputCapabilities() OutputCapability { return CapText | CapAudio }
|
||||
func (d *Speaker) Description() string { return fmt.Sprintf("扬声器 (%s)", d.name) }
|
||||
func (d *Speaker) Start() error { return nil }
|
||||
func (d *Speaker) Stop() error { return nil }
|
||||
func (d *Speaker) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
func (d *Speaker) Description() string { return fmt.Sprintf("扬声器 (%s)", d.name) }
|
||||
func (d *Speaker) Start() error { return nil }
|
||||
func (d *Speaker) Stop() error { return nil }
|
||||
func (d *Speaker) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
|
||||
func (d *Speaker) Tools() []ToolDef {
|
||||
return []ToolDef{{
|
||||
@ -535,13 +631,13 @@ func NewCamera(name string, io *IOManager) *Camera {
|
||||
return &Camera{name: name, io: io}
|
||||
}
|
||||
|
||||
func (d *Camera) Name() string { return d.name }
|
||||
func (d *Camera) Type() DeviceType { return DeviceInput }
|
||||
func (d *Camera) Name() string { return d.name }
|
||||
func (d *Camera) Type() DeviceType { return DeviceInput }
|
||||
func (d *Camera) OutputCapabilities() OutputCapability { return CapImage } // 可返回图片
|
||||
func (d *Camera) Description() string { return fmt.Sprintf("摄像头 (%s)", d.name) }
|
||||
func (d *Camera) Start() error { return nil }
|
||||
func (d *Camera) Stop() error { return nil }
|
||||
func (d *Camera) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
func (d *Camera) Description() string { return fmt.Sprintf("摄像头 (%s)", d.name) }
|
||||
func (d *Camera) Start() error { return nil }
|
||||
func (d *Camera) Stop() error { return nil }
|
||||
func (d *Camera) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
|
||||
func (d *Camera) Tools() []ToolDef {
|
||||
return []ToolDef{
|
||||
@ -583,13 +679,13 @@ func NewRobotArm(name string, io *IOManager) *RobotArm {
|
||||
return &RobotArm{name: name, io: io}
|
||||
}
|
||||
|
||||
func (d *RobotArm) Name() string { return d.name }
|
||||
func (d *RobotArm) Type() DeviceType { return DeviceIO }
|
||||
func (d *RobotArm) Name() string { return d.name }
|
||||
func (d *RobotArm) Type() DeviceType { return DeviceIO }
|
||||
func (d *RobotArm) OutputCapabilities() OutputCapability { return CapStructured }
|
||||
func (d *RobotArm) Description() string { return fmt.Sprintf("机械臂 (%s)", d.name) }
|
||||
func (d *RobotArm) Start() error { return nil }
|
||||
func (d *RobotArm) Stop() error { return nil }
|
||||
func (d *RobotArm) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
func (d *RobotArm) Description() string { return fmt.Sprintf("机械臂 (%s)", d.name) }
|
||||
func (d *RobotArm) Start() error { return nil }
|
||||
func (d *RobotArm) Stop() error { return nil }
|
||||
func (d *RobotArm) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
|
||||
func (d *RobotArm) Tools() []ToolDef {
|
||||
return []ToolDef{
|
||||
@ -635,13 +731,13 @@ func NewGPIODevice(name string, pins []int, io *IOManager) *GPIODevice {
|
||||
return &GPIODevice{name: name, pins: pins, io: io}
|
||||
}
|
||||
|
||||
func (d *GPIODevice) Name() string { return d.name }
|
||||
func (d *GPIODevice) Type() DeviceType { return DeviceIO }
|
||||
func (d *GPIODevice) Name() string { return d.name }
|
||||
func (d *GPIODevice) Type() DeviceType { return DeviceIO }
|
||||
func (d *GPIODevice) OutputCapabilities() OutputCapability { return CapStructured }
|
||||
func (d *GPIODevice) Description() string { return "GPIO 通用引脚" }
|
||||
func (d *GPIODevice) Start() error { return nil }
|
||||
func (d *GPIODevice) Stop() error { return nil }
|
||||
func (d *GPIODevice) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
func (d *GPIODevice) Description() string { return "GPIO 通用引脚" }
|
||||
func (d *GPIODevice) Start() error { return nil }
|
||||
func (d *GPIODevice) Stop() error { return nil }
|
||||
func (d *GPIODevice) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
|
||||
func (d *GPIODevice) Tools() []ToolDef {
|
||||
return []ToolDef{
|
||||
|
||||
111
internal/agent/io/injectopts_test.go
Normal file
111
internal/agent/io/injectopts_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// drainOne 取出一条注入事件;没有则 Fatal。
|
||||
func drainOne(t *testing.T, ch <-chan *InputEvent) *InputEvent {
|
||||
t.Helper()
|
||||
select {
|
||||
case evt := <-ch:
|
||||
return evt
|
||||
default:
|
||||
t.Fatal("没有拿到注入事件")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 零值 InjectOptions 必须与历史的三参数注入产出**完全一致**的 payload。
|
||||
//
|
||||
// 这是兼容性底线:任何按 payload 取字段的下游(事件订阅方、旧内核、
|
||||
// 工具链测试)都不能因为这次改造而看到新键。
|
||||
func TestInjectTextOpts_ZeroValueMatchesLegacyPayload(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.InjectText("src", "hello")
|
||||
legacy := drainOne(t, m.InputChan())
|
||||
|
||||
m2 := NewIOManager()
|
||||
m2.InjectTextOpts("src", "chan", "hello", InjectOptions{})
|
||||
withOpts := drainOne(t, m2.InputChan())
|
||||
|
||||
if len(withOpts.Payload) != len(legacy.Payload) {
|
||||
t.Fatalf("零值注入多出了键:legacy=%v opts=%v", legacy.Payload, withOpts.Payload)
|
||||
}
|
||||
for k, v := range legacy.Payload {
|
||||
if withOpts.Payload[k] != v {
|
||||
t.Fatalf("键 %q 不一致:legacy=%v opts=%v", k, v, withOpts.Payload[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标志位必须出现在事件 payload 上——eventloop 就是从那里读的。
|
||||
func TestInjectTextOpts_CarriesFlags(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.InjectTextOpts("src", "chan", "hello", InjectOptions{
|
||||
NoMemory: true,
|
||||
ContextPolicy: "prune",
|
||||
CleanerName: "clean_me",
|
||||
})
|
||||
evt := drainOne(t, m.InputChan())
|
||||
|
||||
if evt.Payload["no_memory"] != true {
|
||||
t.Errorf("no_memory 未传递: %v", evt.Payload["no_memory"])
|
||||
}
|
||||
if evt.Payload["context_policy"] != "prune" {
|
||||
t.Errorf("context_policy 未传递: %v", evt.Payload["context_policy"])
|
||||
}
|
||||
if evt.Payload["cleaner_name"] != "clean_me" {
|
||||
t.Errorf("cleaner_name 未传递: %v", evt.Payload["cleaner_name"])
|
||||
}
|
||||
if evt.Payload["content"] != "hello" {
|
||||
t.Errorf("content 丢失: %v", evt.Payload["content"])
|
||||
}
|
||||
if evt.OutputChannel != "chan" {
|
||||
t.Errorf("输出通道 = %q,期望 chan", evt.OutputChannel)
|
||||
}
|
||||
}
|
||||
|
||||
// 中断注入走另一条队列,标志位同样要带上(用户已确认中断允许声明 prune)。
|
||||
func TestInjectInterruptTextOpts_CarriesFlags(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.InjectInterruptTextOpts("src", "chan", "alert", InjectOptions{ContextPolicy: "prune"})
|
||||
evt := drainOne(t, m.InputInterruptChan())
|
||||
|
||||
if evt.Payload["context_policy"] != "prune" {
|
||||
t.Errorf("中断注入的 context_policy 未传递: %v", evt.Payload)
|
||||
}
|
||||
if evt.Payload["type"] != "text" || evt.Payload["content"] != "alert" {
|
||||
t.Errorf("中断注入的基本字段不对: %v", evt.Payload)
|
||||
}
|
||||
if _, has := evt.Payload["no_memory"]; has {
|
||||
t.Errorf("未声明的 no_memory 不应出现: %v", evt.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 带媒体的注入同样要带标志位。
|
||||
func TestInjectInputMediaOpts_CarriesFlags(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
blocks := []pubsdk.ContentBlock{{Type: "image_url", ImageURL: &pubsdk.ImageURL{URL: "data:image/png;base64,AA"}}}
|
||||
m.InjectInputMediaOpts("src", "chan", "看图", blocks, InjectOptions{NoMemory: true})
|
||||
evt := drainOne(t, m.InputChan())
|
||||
|
||||
if evt.Payload["no_memory"] != true {
|
||||
t.Errorf("媒体的 no_memory 未传递: %v", evt.Payload)
|
||||
}
|
||||
if _, ok := evt.Payload["media_blocks"]; !ok {
|
||||
t.Errorf("媒体块丢失: %v", evt.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧方法必须继续等价工作(它们是 Opts 变体的零值糖)。
|
||||
func TestLegacyNoMemoryMethodStillSetsFlag(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.InjectTextNoMemoryTo("src", "chan", "quiet")
|
||||
evt := drainOne(t, m.InputChan())
|
||||
if evt.Payload["no_memory"] != true {
|
||||
t.Fatalf("旧 NoMemory 方法应置位: %v", evt.Payload)
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Personality struct {
|
||||
@ -39,3 +41,25 @@ func (p *Personality) InjectPrompt() string {
|
||||
}
|
||||
return fmt.Sprintf("【人格设定】\n%s\n", p.Content)
|
||||
}
|
||||
|
||||
// 会随时间腐坏的人格内容特征。现场:人格卡写死 v0.9.0 与早已删除的 C ABI v2,
|
||||
// 实例被问版本时自述错误(v1.2.0 压测发现)。
|
||||
var (
|
||||
personaVersionRe = regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`)
|
||||
personaStalePhrases = []string{"C ABI v2", "plugin.so", "c-shared", "描述式索引", "引用计数式"}
|
||||
)
|
||||
|
||||
// PersonaStaleHints 返回人格文本里会腐坏的内容(空 = 干净)。
|
||||
// 供启动时告警:引导改用配置项 core.agent.personal_prompt(默认模板不含这些)。
|
||||
func PersonaStaleHints(content string) []string {
|
||||
var out []string
|
||||
if m := personaVersionRe.FindAllString(content, -1); len(m) > 0 {
|
||||
out = append(out, fmt.Sprintf("版本号字面量 %v(版本应来自运行时快照)", m))
|
||||
}
|
||||
for _, p := range personaStalePhrases {
|
||||
if strings.Contains(content, p) {
|
||||
out = append(out, "可能已过期的说法: "+p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
62
internal/agent/personal_test.go
Normal file
62
internal/agent/personal_test.go
Normal file
@ -0,0 +1,62 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// PersonaStaleHints 必须能认出会腐坏的人格内容——版本号字面量与已删除机制。
|
||||
func TestPersonaStaleHints(t *testing.T) {
|
||||
// 现场真实文本(线上人格卡的原文)
|
||||
stale := "你是 HomeAgent(内核代号 HΔ-Kernel,当前版本 v0.9.0)。\n当前运行的二进制是 v0.9.0(C ABI v2,构建于 2026-08-15)。"
|
||||
hints := PersonaStaleHints(stale)
|
||||
if len(hints) == 0 {
|
||||
t.Fatal("未识别出写死版本号与 C ABI 的人格文本")
|
||||
}
|
||||
joined := strings.Join(hints, " | ")
|
||||
if !strings.Contains(joined, "版本号字面量") {
|
||||
t.Fatalf("应报出版本号字面量,实际: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "C ABI v2") {
|
||||
t.Fatalf("应报出已删除机制的残留说法,实际: %s", joined)
|
||||
}
|
||||
|
||||
// 干净文本(配置项默认模板)不应误报
|
||||
if h := PersonaStaleHints(DefaultPersonaProbeClean()); len(h) != 0 {
|
||||
t.Fatalf("干净人格被误报: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultPersonaProbeClean 由 config 包的默认模板等价物构成——
|
||||
// 这里不复用 config 包以避免 import cycle,只断言「不含版本号与旧机制」的文本不被误报。
|
||||
func DefaultPersonaProbeClean() string {
|
||||
return "你是 HomeAgent(内核代号 HΔ-Kernel)。外部插件是独立子进程,经 stdio JSON-RPC 通信;" +
|
||||
"被问到版本时以运行时快照为准。"
|
||||
}
|
||||
|
||||
func TestLoadAndSavePersonality(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "personal", "personal.md")
|
||||
|
||||
// 文件不存在时返回空(不报错、不创建)
|
||||
p, err := LoadPersonality(path)
|
||||
if err != nil || p == nil || p.Content != "" {
|
||||
t.Fatalf("缺文件时应返回空人格,实际 %+v err=%v", p, err)
|
||||
}
|
||||
|
||||
if err := SavePersonality(path, "人格内容"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("SavePersonality 未落盘: %v", err)
|
||||
}
|
||||
p, err = LoadPersonality(path)
|
||||
if err != nil || p.Content != "人格内容" {
|
||||
t.Fatalf("读回失败: %+v err=%v", p, err)
|
||||
}
|
||||
if got := p.InjectPrompt(); !strings.Contains(got, "【人格设定】") || !strings.Contains(got, "人格内容") {
|
||||
t.Fatalf("InjectPrompt 形状不对: %q", got)
|
||||
}
|
||||
}
|
||||
139
internal/config/persona.go
Normal file
139
internal/config/persona.go
Normal file
@ -0,0 +1,139 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 人格设定的两个键:内容与「首启向导/工具已经问过」的一次性标记。
|
||||
//
|
||||
// 为什么需要标记:人格曾经只有 <dataDir>/personal/personal.md 一个来源且无人维护,
|
||||
// 里面写死的旧版本号反过来让实例自述旧版本(v1.2.0 压测发现)。
|
||||
// 现在人格是配置项(默认模板不含任何版本号),任何通道的第一次交互问一次,之后不再打扰。
|
||||
const (
|
||||
// PersonaPromptKey 是人格设定内容(【人格设定】块的正文)。
|
||||
PersonaPromptKey = "core.agent.personal_prompt"
|
||||
// PersonaInitMarkerKey 是「已经问过/已确认」的一次性标记。
|
||||
PersonaInitMarkerKey = "core.internal.persona_initialized"
|
||||
)
|
||||
|
||||
// 三种落库方式,WebUI 首启向导与内核 persona_set 工具共用。
|
||||
const (
|
||||
PersonaModeDefault = "default" // 使用内置默认模板
|
||||
PersonaModeCustom = "custom" // 使用调用方提供的内容
|
||||
PersonaModeLater = "later" // 保留当前(默认)人格,只打标记不再问
|
||||
)
|
||||
|
||||
// PersonaKV 是人格落库所需的最小读写面:GetCore/SetCore 的签名与
|
||||
// 公开 SDK 的 SettingsAPI 一致,因此插件侧(WebUI)可直接传入;
|
||||
// 内核直连配置注册表时用 registryKV 适配(见文件末)。
|
||||
type PersonaKV interface {
|
||||
GetCore(key string) (interface{}, error)
|
||||
SetCore(key string, value interface{}) error
|
||||
}
|
||||
|
||||
// PersonaInitializedKV 报告人格是否已确认(向导或工具已问过)。
|
||||
func PersonaInitializedKV(kv PersonaKV) bool {
|
||||
if kv == nil {
|
||||
return false
|
||||
}
|
||||
v, err := kv.GetCore(PersonaInitMarkerKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s, _ := v.(string)
|
||||
return strings.TrimSpace(s) != ""
|
||||
}
|
||||
|
||||
// CurrentPersonaKV 读当前人格内容;未设置时回落到内置默认模板。
|
||||
func CurrentPersonaKV(kv PersonaKV) string {
|
||||
if kv == nil {
|
||||
return DefaultPersonaPrompt
|
||||
}
|
||||
if v, err := kv.GetCore(PersonaPromptKey); err == nil {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return DefaultPersonaPrompt
|
||||
}
|
||||
|
||||
// SetPersonaKV 落库人格并打一次性标记,返回 restartRequired。
|
||||
//
|
||||
// 生效时机:人格在 homed 启动时载入(拼成【人格设定】块进系统提示词),
|
||||
// 所以**自定义内容需重启**;default 与 later 都不改变当前已生效的人格,无需重启。
|
||||
//
|
||||
// 非法输入一律在打标记之前拒绝——否则向导会被跳过,用户再也没机会设。
|
||||
func SetPersonaKV(kv PersonaKV, mode, content string) (restartRequired bool, err error) {
|
||||
if kv == nil {
|
||||
return false, fmt.Errorf("persona: settings unavailable")
|
||||
}
|
||||
switch mode {
|
||||
case PersonaModeDefault:
|
||||
if err := kv.SetCore(PersonaPromptKey, DefaultPersonaPrompt); err != nil {
|
||||
return false, err
|
||||
}
|
||||
case PersonaModeCustom:
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return false, fmt.Errorf("persona: content required for custom mode")
|
||||
}
|
||||
if err := kv.SetCore(PersonaPromptKey, content); err != nil {
|
||||
return false, err
|
||||
}
|
||||
restartRequired = true
|
||||
case PersonaModeLater:
|
||||
// 保持当前人格(通常是默认模板),只打标记
|
||||
default:
|
||||
return false, fmt.Errorf("persona: unknown mode %q (want default|custom|later)", mode)
|
||||
}
|
||||
if err := kv.SetCore(PersonaInitMarkerKey, "1"); err != nil {
|
||||
return restartRequired, err
|
||||
}
|
||||
return restartRequired, nil
|
||||
}
|
||||
|
||||
// RegistryPersonaStore 把配置注册表暴露成内核的 core.PersonaStore 接口
|
||||
// (结构类型:方法集匹配即可,无需 import internal/agent/core)。
|
||||
type RegistryPersonaStore struct{ Reg *ConfigRegistry }
|
||||
|
||||
func (p RegistryPersonaStore) PersonaInitialized() bool { return PersonaInitialized(p.Reg) }
|
||||
|
||||
func (p RegistryPersonaStore) SetPersona(mode, content string) (bool, error) {
|
||||
return SetPersona(p.Reg, mode, content)
|
||||
}
|
||||
|
||||
// registryKV 把内核直连的配置注册表适配成 PersonaKV。
|
||||
type registryKV struct{ reg *ConfigRegistry }
|
||||
|
||||
func (r registryKV) GetCore(key string) (interface{}, error) {
|
||||
v, err := r.reg.Get(key)
|
||||
if err != nil || v == nil {
|
||||
// 未设置的键对 PersonaKV 语义等同「没有」,不当作错误
|
||||
if s := r.reg.GetString(key, ""); s != "" {
|
||||
return s, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r registryKV) SetCore(key string, value interface{}) error {
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("persona: value must be a string")
|
||||
}
|
||||
return r.reg.Set(key, s)
|
||||
}
|
||||
|
||||
// PersonaInitialized 报告人格是否已确认(内核直连注册表)。
|
||||
func PersonaInitialized(reg *ConfigRegistry) bool {
|
||||
return reg != nil && PersonaInitializedKV(registryKV{reg})
|
||||
}
|
||||
|
||||
// SetPersona 落库人格并打一次性标记(内核直连注册表)。
|
||||
func SetPersona(reg *ConfigRegistry, mode, content string) (bool, error) {
|
||||
if reg == nil {
|
||||
return false, fmt.Errorf("persona: config registry unavailable")
|
||||
}
|
||||
return SetPersonaKV(registryKV{reg}, mode, content)
|
||||
}
|
||||
99
internal/config/persona_kv_test.go
Normal file
99
internal/config/persona_kv_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeKV 是 PersonaKV 的最小实现(模拟插件侧 SettingsAPI)。
|
||||
type fakeKV struct{ m map[string]string }
|
||||
|
||||
func (f *fakeKV) GetCore(k string) (interface{}, error) { return f.m[k], nil }
|
||||
func (f *fakeKV) SetCore(k string, v interface{}) error {
|
||||
f.m[k] = v.(string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 三选一语义 + 「只问一次」标记:这是首启向导与 persona_set 工具共用的同一份实现。
|
||||
func TestSetPersonaKV(t *testing.T) {
|
||||
t.Run("custom 写入内容并要求重启", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
restart, err := SetPersonaKV(kv, PersonaModeCustom, "你是测试人格")
|
||||
if err != nil || !restart {
|
||||
t.Fatalf("custom 应成功且需重启: restart=%v err=%v", restart, err)
|
||||
}
|
||||
if kv.m[PersonaPromptKey] != "你是测试人格" || kv.m[PersonaInitMarkerKey] != "1" {
|
||||
t.Fatalf("落库不对: %+v", kv.m)
|
||||
}
|
||||
if !PersonaInitializedKV(kv) {
|
||||
t.Fatal("打过标记应报告已确认")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default 写默认模板且无需重启", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
restart, err := SetPersonaKV(kv, PersonaModeDefault, "")
|
||||
if err != nil || restart {
|
||||
t.Fatalf("default 不应需重启: restart=%v err=%v", restart, err)
|
||||
}
|
||||
if kv.m[PersonaPromptKey] != DefaultPersonaPrompt {
|
||||
t.Fatal("default 应写入内置默认模板")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("later 保持人格并打标记", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{PersonaPromptKey: "原有人格"}}
|
||||
if _, err := SetPersonaKV(kv, PersonaModeLater, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kv.m[PersonaPromptKey] != "原有人格" {
|
||||
t.Fatal("later 不应改动人格")
|
||||
}
|
||||
if kv.m[PersonaInitMarkerKey] != "1" {
|
||||
t.Fatal("later 也必须打标记(否则每次启动都问)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非法输入必须拒绝且不打标记", func(t *testing.T) {
|
||||
for _, c := range []struct{ mode, content string }{
|
||||
{PersonaModeCustom, " "}, // 空内容
|
||||
{"nope", ""}, // 未知 mode
|
||||
} {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
if _, err := SetPersonaKV(kv, c.mode, c.content); err == nil {
|
||||
t.Fatalf("mode=%q content=%q 应报错", c.mode, c.content)
|
||||
}
|
||||
if kv.m[PersonaInitMarkerKey] == "1" {
|
||||
t.Fatalf("mode=%q 被拒时不得打标记(否则向导会被跳过)", c.mode)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CurrentPersonaKV 未设置时回落默认", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
if got := CurrentPersonaKV(kv); got != DefaultPersonaPrompt {
|
||||
t.Fatal("未设置应回落默认模板")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 内核直连注册表的入口必须与 KV 版行为一致(同一份实现的两个薄入口)。
|
||||
func TestSetPersonaRegistryEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewConfigRegistry(dir + "/config.db")
|
||||
reg.SeedDefaults(dir)
|
||||
defer reg.Close()
|
||||
|
||||
if PersonaInitialized(reg) {
|
||||
t.Fatal("全新实例不应已确认人格")
|
||||
}
|
||||
if _, err := SetPersona(reg, PersonaModeCustom, "内核侧人格"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !PersonaInitialized(reg) {
|
||||
t.Fatal("内核侧落库后应报告已确认")
|
||||
}
|
||||
if got := reg.GetString(PersonaPromptKey, ""); !strings.Contains(got, "内核侧人格") {
|
||||
t.Fatalf("注册表里没有人格内容: %q", got)
|
||||
}
|
||||
}
|
||||
48
internal/config/persona_test.go
Normal file
48
internal/config/persona_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 人格模板的契约:**不得写死版本号**。
|
||||
//
|
||||
// 来历:线上人格卡(personal/personal.md)曾写死「当前版本 v0.9.0(C ABI v2)」,
|
||||
// 而 C ABI 早在 v1.0.0 就被删除。结果是内核自身日志/接口都报 1.2.0,
|
||||
// agent 被问版本时却按人格卡自述旧版本(v1.2.0 压测发现)。
|
||||
// 版本应来自运行时快照,不来自任何会被发版落下的文本。
|
||||
func TestDefaultPersonaPromptHasNoVersionLiterals(t *testing.T) {
|
||||
re := regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`)
|
||||
if m := re.FindAllString(DefaultPersonaPrompt, -1); len(m) > 0 {
|
||||
t.Fatalf("默认人格模板含版本号字面量 %v —— 发版后必然腐坏,"+
|
||||
"被问版本时应要求 agent 读运行时快照", m)
|
||||
}
|
||||
if !strings.Contains(DefaultPersonaPrompt, "运行时快照") {
|
||||
t.Fatal("默认人格模板必须显式要求「版本以运行时快照为准」,否则模型会凭记忆编造版本")
|
||||
}
|
||||
}
|
||||
|
||||
// 人格配置项必须注册、默认值就是 DefaultPersonaPrompt(单一事实源),
|
||||
// 且全新安装时会被播种进 DB。
|
||||
func TestPersonaPromptRegisteredWithDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||||
r.SeedDefaults(dir)
|
||||
defer r.Close()
|
||||
|
||||
def := r.GetDef("core.agent.personal_prompt")
|
||||
if def == nil {
|
||||
t.Fatal("core.agent.personal_prompt 未注册")
|
||||
}
|
||||
if def.Default != DefaultPersonaPrompt {
|
||||
t.Fatalf("默认值与 DefaultPersonaPrompt 不一致:%q", def.Default)
|
||||
}
|
||||
if def.Type != "text" {
|
||||
t.Fatalf("人格设定应为多行文本类型,实际 %q", def.Type)
|
||||
}
|
||||
if got := r.GetString("core.agent.personal_prompt", ""); got != DefaultPersonaPrompt {
|
||||
t.Fatalf("播种未写入默认人格(长度 %d)", len(got))
|
||||
}
|
||||
}
|
||||
@ -472,6 +472,36 @@ var defaultSources = map[string]map[string]string{
|
||||
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||
}
|
||||
|
||||
// DefaultPersonaPrompt 是「人格设定」的默认模板,作为配置项 core.agent.personal_prompt 的默认值。
|
||||
//
|
||||
// 契约(由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住):
|
||||
// - **不得含版本号字面量**。写死的版本会随发版腐坏,反过来让实例自称旧版本
|
||||
// (现场:人格卡写死 v0.9.0 与早已删除的 C ABI,实例被问版本时自述错误)。
|
||||
// 被问到版本/构建信息时,要求 agent 读运行时快照。
|
||||
// - 不得把已删除的机制当作现行机制描述。
|
||||
const DefaultPersonaPrompt = `你是 HomeAgent(内核代号 HΔ-Kernel)——一个完全独立自研的新一代 Agent 框架。
|
||||
你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。
|
||||
内核(homed 守护进程)只负责 LLM 编排、记忆管理与知识检索,全部 IO 能力由插件承载。
|
||||
外部插件是独立子进程,经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)通信;
|
||||
旧式 C ABI 动态库产物早已不再加载。
|
||||
**不要凭记忆断言版本号或构建日期**:被问到时以运行时快照(healthcheck_kernel 的内核版本字段)为准。
|
||||
|
||||
## 对用户的称呼
|
||||
|
||||
你对用户的称呼永远是“老大”,绝对禁止使用“老板”“主人”称呼用户,不论任何情况。
|
||||
|
||||
## 对话风格
|
||||
|
||||
- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着
|
||||
- 重要的事先说结论,再展开解释
|
||||
- 回复要简洁自然
|
||||
|
||||
## 能力边界
|
||||
|
||||
- 你通过插件编排所有 IO:QQ/微信消息、WebUI、终端、文件、网络
|
||||
- 输出不会自动路由到对话通道:QQ/微信等异步通道必须调用输出门工具(output_send__qq 等)才能真正送达
|
||||
- 你的三层记忆(Context → Document → Graph)持续蒸馏归档,超长运行时记忆不衰减`
|
||||
|
||||
func (r *ConfigRegistry) SeedDefaults(dataDir string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@ -482,9 +512,30 @@ func (r *ConfigRegistry) SeedDefaults(dataDir string) {
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
var count int
|
||||
r.db.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&count)
|
||||
if count > 0 {
|
||||
// 新鲜度判据不能是「config 表非空」。
|
||||
//
|
||||
// 发行包的 postinst 会先跑 setup.sh → initconfig,而 initconfig 会写一行
|
||||
// webui.listen_addr。于是**全新安装**的 DB 看上去"已经有内容",整个默认值
|
||||
// 播种被跳过:core.plugin.dir、core.memory.*、多模态 provider 一个都没写。
|
||||
// 现场表现是装完 0 个插件、随包的模型与运行库成死重量。
|
||||
//
|
||||
// 也不能改成"每次都补缺键":老安装升级时被注进新默认值,会让它突然
|
||||
// 去加载一个 1.8GB 的模型——那是刻意要避免的行为(静默变重)。
|
||||
//
|
||||
// 故用显式标记区分三种情形:
|
||||
// 有标记 → 已经播过种,直接返回
|
||||
// 无标记但有 core.daemon.data_dir → 老安装(本键历来由播种写入),
|
||||
// 只补标记、不播种
|
||||
// 两者都没有 → 全新安装,播种并打标记
|
||||
const markerKey = "core.internal.seed_version"
|
||||
var hasMarker, hasLegacy int
|
||||
r.db.QueryRow(`SELECT COUNT(*) FROM config WHERE key = ?`, markerKey).Scan(&hasMarker)
|
||||
if hasMarker > 0 {
|
||||
return
|
||||
}
|
||||
r.db.QueryRow(`SELECT COUNT(*) FROM config WHERE key = 'core.daemon.data_dir'`).Scan(&hasLegacy)
|
||||
if hasLegacy > 0 {
|
||||
r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, markerKey, "1")
|
||||
return
|
||||
}
|
||||
|
||||
@ -502,6 +553,10 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
|
||||
set := func(k, v string) { stmt.Exec(k, v) }
|
||||
|
||||
// 人格设定:与其它默认值同批播种(老安装不会被注入——那是刻意的)。
|
||||
// 存量安装里若还有人 personality 文件,它优先于本项(见 cmd/homed/main.go)。
|
||||
set("core.agent.personal_prompt", DefaultPersonaPrompt)
|
||||
|
||||
set("webui.listen_addr", ":8080")
|
||||
set("core.daemon.data_dir", dataDir)
|
||||
set("core.daemon.heartbeat_interval", "15s")
|
||||
@ -545,6 +600,13 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
set("core.memory.text", filepath.Join(dataDir, "memory", "text"))
|
||||
set("core.memory.documents", filepath.Join(dataDir, "memory", "documents"))
|
||||
set("core.memory.media.dir", filepath.Join(dataDir, "memory", "media"))
|
||||
// 发行版默认启用本地向量空间。用 chineseclip(text+image、512 维、实测
|
||||
// 常驻 1.15GB、Apache-2.0)而不是 qwen3vl(9.4GB):多数机器装不下后者。
|
||||
// 产物不在仓库里,用 scripts/export_chineseclip_onnx.py 生成到这个路径;
|
||||
// 产物缺失时 homed 会打印明确错误并退回 fastText 文本路径(不静默假装启用)。
|
||||
set("core.memory.multimodal_space.provider", "chineseclip")
|
||||
set("core.memory.multimodal_space.options.model_dir",
|
||||
filepath.Join(dataDir, "models", "chinese-clip-vit-b16-onnx"))
|
||||
set("core.knowledge.path", filepath.Join(dataDir, "knowledge"))
|
||||
set("core.log.path", filepath.Join(dataDir, "log"))
|
||||
|
||||
@ -597,12 +659,21 @@ WebUI 概览页展示你的立绘,可通过 /mascot.webp 直接访问。如输
|
||||
set("core.input_processing.audio.fallback_model", "")
|
||||
set("core.input_processing.audio.describe_prompt", "请转写这段音频的内容。")
|
||||
|
||||
set(markerKey, "1")
|
||||
|
||||
tx.Commit()
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
||||
reg := func(d ConfigDef) { r.defs[d.Key] = &d }
|
||||
|
||||
// 人格设定(人格卡的配置项化):默认模板见 DefaultPersonaPrompt。
|
||||
// 高级用户仍可用 <dataDir>/personal/personal.md 覆盖它。
|
||||
reg(ConfigDef{Key: "core.agent.personal_prompt", Default: DefaultPersonaPrompt, Type: "text", DisplayName: "人格设定",
|
||||
Description: "人格设定块(作为【人格设定】拼在系统提示词之前)。留空则该块不注入。" +
|
||||
"默认模板不含版本号:被问到版本/构建信息时,应读运行时快照而非凭记忆断言。" +
|
||||
"<dataDir>/personal/personal.md 存在且非空时优先于本项。", Category: "agent"})
|
||||
|
||||
reg(ConfigDef{Key: "webui.listen_addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "WebUI HTTP 监听地址", Category: "webui"})
|
||||
reg(ConfigDef{Key: "core.daemon.data_dir", Default: dataDir, Type: "string", DisplayName: "数据目录", Description: "数据存储根目录", Category: "daemon"})
|
||||
reg(ConfigDef{Key: "core.daemon.heartbeat_interval", Default: "15s", Type: "duration", DisplayName: "心跳间隔", Description: "Agent 心跳检查间隔", Category: "daemon"})
|
||||
@ -649,12 +720,16 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
||||
reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频按内容摘要(sha256)落盘去重,记忆各层只记 digest。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频变成一等记忆块,内容按 sha256 落盘去重。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期;0 表示不自动清理", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0,靠这个保护期避免被误删", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。**描述文本才是持久语义记忆**——blob 会被容量 GC 淘汰,描述会随记忆各层一直留存并可检索。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.provider", Default: "chineseclip", Type: "string", DisplayName: "多模态向量 provider", Description: "从公共 provider 注册表(pkg/embedding)按名字打开的多模态向量空间。内置:chineseclip(默认,text+image,512 维,实测常驻 1.15GB,Apache-2.0)、qwen3vl(text+image,2048 维,常驻 9.4GB;视频已实现但未纳入契约)、http(外部向量 API)。也可是第三方注册的名字。两者均需 onnxruntime 构建标签。留空禁用多模态向量检索,只保留 fastText 文本路径。provider 的模型文件、预处理与运行时全在 provider 内部,核心不做任何模型假设。修改后需重启生效。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.options.model_dir", Default: filepath.Join(dataDir, "models", "chinese-clip-vit-b16-onnx"), Type: "string", DisplayName: "provider 模型目录", Description: "provider 自定义选项(以 options. 开头的键会去掉前缀后原样传给 provider,核心不解释其含义)。对内置 chineseclip:Chinese-CLIP 产物目录(用 scripts/export_chineseclip_onnx.py 生成)。对内置 qwen3vl:Qwen3-VL ONNX 产物目录(用 scripts/export_qwen3vl_embedding_onnx.py 生成)。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.options.endpoint", Default: "", Type: "string", DisplayName: "provider 服务端点", Description: "provider 自定义选项。对内置 http:外部多模态向量服务的端点 URL(POST,接受 modality/side/text/data/mime,返回 embedding)。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.options.api_key", Default: "", Type: "password", DisplayName: "provider 服务密钥", Description: "provider 自定义选项。对内置 http:作为 Bearer token 发送。可选。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.options.model", Default: "", Type: "string", DisplayName: "provider 模型标识", Description: "provider 自定义选项。对内置 http:外部服务使用的模型名,作为 vec_model 持久化。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.options.dimension", Default: "0", Type: "int", DisplayName: "provider 向量维度", Description: "provider 自定义选项。对内置 http:服务返回的特征向量维度,必须与实际返回值一致。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.options.timeout", Default: "30s", Type: "duration", DisplayName: "provider 请求超时", Description: "provider 自定义选项。对内置 http:单次向量请求的超时时间。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.options.fingerprint", Default: "", Type: "string", DisplayName: "provider 空间指纹", Description: "provider 自定义选项。对内置 http:向量空间版本标识(留空时根据 model+dim 自动生成)。指纹变化会触发历史向量重算。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"})
|
||||
|
||||
|
||||
@ -188,6 +188,65 @@ func TestSeedDefaultsToConfig(t *testing.T) {
|
||||
r.Close()
|
||||
}
|
||||
|
||||
// 发行包全新安装:postinst 先跑 setup.sh → initconfig,而 initconfig 只写
|
||||
// webui.listen_addr。于是 config 表已经非空,旧实现据此判定“已有配置”并整体
|
||||
// 跳过播种——装完没有 core.plugin.dir(0 个插件)、也没有随包模型对应的
|
||||
// 多模态 provider(754MB 产物 + 24MB 运行库全成死重量)。
|
||||
func TestSeedDefaultsAfterInitconfigPrepopulate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.db")
|
||||
|
||||
r := NewConfigRegistry(path)
|
||||
// 精确复现 initconfig 的唯一一笔写入
|
||||
if _, err := r.db.Exec(`INSERT INTO config (key, value) VALUES ('webui.listen_addr', ':8080')`); err != nil {
|
||||
t.Fatalf("预置 initconfig 行: %v", err)
|
||||
}
|
||||
|
||||
r.SeedDefaults(dir)
|
||||
|
||||
for _, k := range []string{"core.daemon.data_dir", "core.plugin.dir", "core.memory.multimodal_space.provider"} {
|
||||
if r.GetString(k, "") == "" {
|
||||
t.Fatalf("全新安装(initconfig 已写 webui.listen_addr)后 %s 仍为空:默认值播种被跳过", k)
|
||||
}
|
||||
}
|
||||
if got := r.GetString("core.memory.multimodal_space.provider", ""); got != "chineseclip" {
|
||||
t.Fatalf("随包默认 provider 应为 chineseclip,实为 %q", got)
|
||||
}
|
||||
r.Close()
|
||||
}
|
||||
|
||||
// 老安装升级:绝不能因为新版本加了默认值就把它注进现有 DB——那会让升级即
|
||||
// 静默加载一个 1.8GB 的模型。判据是 core.daemon.data_dir 在场(老安装由播种
|
||||
// 写入)而 seed 标记缺失。
|
||||
func TestSeedDefaultsDoesNotInjectIntoLegacyInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.db")
|
||||
|
||||
r := NewConfigRegistry(path)
|
||||
if _, err := r.db.Exec(`INSERT INTO config (key, value) VALUES ('core.daemon.data_dir', ?)`, dir); err != nil {
|
||||
t.Fatalf("预置老安装行: %v", err)
|
||||
}
|
||||
|
||||
r.SeedDefaults(dir)
|
||||
|
||||
if got := r.GetString("core.memory.multimodal_space.provider", ""); got != "" {
|
||||
t.Fatalf("老安装升级被注入新默认值 provider=%q(升级后会静默加载大模型)", got)
|
||||
}
|
||||
if got := r.GetString("core.plugin.dir", ""); got != "" {
|
||||
t.Fatalf("老安装升级被注入新默认值 core.plugin.dir=%q", got)
|
||||
}
|
||||
|
||||
// 但标记必须补上,否则每次启动都会重走判断
|
||||
var n int
|
||||
if err := r.db.QueryRow(`SELECT COUNT(*) FROM config WHERE key = 'core.internal.seed_version'`).Scan(&n); err != nil {
|
||||
t.Fatalf("查 seed 标记: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("老安装应补上 seed 标记,实际 count=%d", n)
|
||||
}
|
||||
r.Close()
|
||||
}
|
||||
|
||||
func TestGetHelpers(t *testing.T) {
|
||||
r := NewConfigRegistry("")
|
||||
r.Set("str_key", "hello")
|
||||
|
||||
@ -28,17 +28,17 @@ type Knowledge struct {
|
||||
// IndexItem — 索引条目,包含向量特征和内容摘要
|
||||
type IndexItem struct {
|
||||
Name string `json:"name"`
|
||||
Preview string `json:"preview"` // 前 200 字摘要
|
||||
Preview string `json:"preview"` // 前 200 字摘要
|
||||
Tags []string `json:"tags"`
|
||||
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征)
|
||||
Size int `json:"size"` // 内容总字节数
|
||||
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征)
|
||||
Size int `json:"size"` // 内容总字节数
|
||||
}
|
||||
|
||||
// TreeIndex — 树状索引节点
|
||||
type TreeIndex struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Children map[string]*TreeIndex `json:"children,omitempty"`
|
||||
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
|
||||
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
|
||||
}
|
||||
|
||||
func newTreeIndex(name string) *TreeIndex {
|
||||
@ -80,11 +80,20 @@ type Store struct {
|
||||
root string
|
||||
vec *vector.Store
|
||||
veczer *vector.TFIDFVectorizer
|
||||
mu sync.RWMutex
|
||||
items map[string]*Knowledge
|
||||
|
||||
// lex 是**词法路**索引(TF-IDF),与 vec(稠密路:词向量/多模态空间)相互独立。
|
||||
//
|
||||
// 为何要两路:词向量取平均后各向异性明显——所有文档都挤在语料均值方向附近,
|
||||
// 真实 KB(33 条)上自检索 top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声。
|
||||
// 融合后 MRR 0.271→0.376、前两名差距 0.013→0.128(同一份数据实测),
|
||||
// 且「词都在停用词里」的查询(稠密路给空向量)能靠词法路救回来。
|
||||
lex *vector.Store
|
||||
|
||||
mu sync.RWMutex
|
||||
items map[string]*Knowledge
|
||||
summaries []string
|
||||
|
||||
indexPath string
|
||||
summaries []string
|
||||
vectorizer vector.Vectorizer // 可选:词嵌入向量化器,优先于 TF-IDF
|
||||
}
|
||||
|
||||
@ -93,11 +102,20 @@ func NewStore(root string) *Store {
|
||||
root: root,
|
||||
indexPath: filepath.Join(root, ".index.json"),
|
||||
vec: vector.NewStore(),
|
||||
lex: newLexicalStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords),
|
||||
items: make(map[string]*Knowledge),
|
||||
}
|
||||
}
|
||||
|
||||
// newLexicalStore 造词法路存储。阈值设为 0:TF-IDF 余弦量级只有 0.0~0.2,
|
||||
// 沿用稠密路的 0.05 会把大量有效候选静默砍掉(实测 MRR 0.307→0.193)。
|
||||
func newLexicalStore() *vector.Store {
|
||||
st := vector.NewStore()
|
||||
st.SetMinScore(0)
|
||||
return st
|
||||
}
|
||||
|
||||
// SetVectorizer 设置词嵌入向量化器,优先于 TF-IDF
|
||||
func (s *Store) SetVectorizer(v vector.Vectorizer) {
|
||||
s.vectorizer = v
|
||||
@ -110,13 +128,19 @@ func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) {
|
||||
|
||||
log.Printf("[knowledge] reindex with vectorizer (%d items)", len(s.items))
|
||||
s.vec = vector.NewStore()
|
||||
s.lex = newLexicalStore()
|
||||
// 词法路的 IDF 必须建在全语料上(否则 IDF 没意义)
|
||||
if len(s.summaries) > 0 {
|
||||
s.veczer.Train(s.summaries)
|
||||
}
|
||||
for _, k := range s.items {
|
||||
vec := v.Vectorize(k.Name + " " + k.Content)
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
|
||||
text := k.Name + " " + k.Content
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, v.Vectorize(text), map[string]string{
|
||||
"name": k.Name, "path": k.Path,
|
||||
})
|
||||
s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil)
|
||||
}
|
||||
log.Printf("[knowledge] reindex with vectorizer complete (%d vectors)", s.vec.Size())
|
||||
log.Printf("[knowledge] reindex complete (dense=%d lex=%d)", s.vec.Size(), s.lex.Size())
|
||||
}
|
||||
|
||||
// vectorize 优先使用词嵌入向量化器,不可用时回退到 TF-IDF
|
||||
@ -144,6 +168,17 @@ func (s *Store) Start() error {
|
||||
|
||||
func (s *Store) Stop() {}
|
||||
|
||||
// 融合权重:稠密路(词向量)与词法路(TF-IDF)。
|
||||
// 取值由真实 KB 上的权重扫描定(rankdiag_test.go 的 KB_DIAG_SWEEP):
|
||||
// 1.0 = 修复前的「只用稠密路」行为,作为对照基线。
|
||||
var densePathWeight = 0.5
|
||||
|
||||
// Search 融合两路召回:稠密路(词向量/多模态空间)+ 词法路(TF-IDF)。
|
||||
//
|
||||
// 为何不能只用稠密路:词向量取平均后各向异性明显,真实 KB 上自检索 top-1 只有 15%,
|
||||
// 前两名平均只差 0.013(等于没区分度);且全为停用词的查询会得到**空向量**,
|
||||
// 直接搜不出任何东西("最近更新" 就撞上这个)。词法路对专名/术语/短查询强,
|
||||
// 两路各自**按查询内最大值归一化**后加权融合,排序才可信。
|
||||
func (s *Store) Search(query string, topK int) []*Knowledge {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@ -151,15 +186,58 @@ func (s *Store) Search(query string, topK int) []*Knowledge {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
if s.vec.Size() == 0 && s.lex.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
// 两路各自对**全部**文档打分:
|
||||
// - 稠密路的特征是维索引,几乎每篇都命中,"候选"就是全量;
|
||||
// - 词法路只召回与查询共词的文档(这正是它的长处:专名/术语)。
|
||||
// 为何不先截候选再融合:截断后只能拿**候选内**最大值归一化,路与路之间的
|
||||
// 相对权重就随候选集漂移——实测同一份 KB 上自检索 MRR 从 0.376 掉到 0.197。
|
||||
// KB 规模下全量 cosine 的代价可忽略;真到数万条再上 ANN 也不迟。
|
||||
denseHits := s.vec.SearchScored(s.vectorize(query), s.vec.Size())
|
||||
lexHits := s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size())
|
||||
if len(denseHits) == 0 && len(lexHits) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
vec := s.vectorize(query)
|
||||
results := s.vec.Search(vec, topK)
|
||||
scores := make(map[string]float64, len(denseHits)+len(lexHits))
|
||||
addPath := func(hits []vector.DocVectorHit, weight float64) {
|
||||
max := 0.0
|
||||
for _, h := range hits {
|
||||
if h.Score > max {
|
||||
max = h.Score
|
||||
}
|
||||
}
|
||||
if max <= 0 {
|
||||
return // 该路对这条查询没有信号(如空向量),全量让给另一路
|
||||
}
|
||||
for _, h := range hits {
|
||||
scores[h.Doc.ID] += weight * h.Score / max
|
||||
}
|
||||
}
|
||||
addPath(denseHits, densePathWeight)
|
||||
addPath(lexHits, 1-densePathWeight)
|
||||
|
||||
ids := make([]string, 0, len(scores))
|
||||
for id := range scores {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
if scores[ids[i]] != scores[ids[j]] {
|
||||
return scores[ids[i]] > scores[ids[j]]
|
||||
}
|
||||
return ids[i] < ids[j] // 分数相同时按名字定序(保证结果可重复)
|
||||
})
|
||||
|
||||
var out []*Knowledge
|
||||
for _, r := range results {
|
||||
if k, ok := s.items[r.ID]; ok {
|
||||
for _, id := range ids {
|
||||
if k, ok := s.items[id]; ok {
|
||||
out = append(out, k)
|
||||
}
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -201,17 +279,26 @@ func (s *Store) Add(name, content string) error {
|
||||
}
|
||||
s.items[id] = k
|
||||
|
||||
vec := s.vectorize(name + " " + content)
|
||||
// 覆盖同名条目时必须先摘掉旧向量。
|
||||
//
|
||||
// vector.Store.Insert 是**追加**语义(s.docs = append + index.Add),不按 id
|
||||
// 去重。少了这一步,更新一条知识会在向量索引里留下上一版的副本:条目数看起来
|
||||
// 是对的,只有向量数比条目数多——而检索可能因此命中已被替换掉的旧内容。
|
||||
s.vec.Remove(id)
|
||||
|
||||
text := name + " " + content
|
||||
vec := s.vectorize(text)
|
||||
s.vec.Insert(id, name+": "+content, vec, map[string]string{
|
||||
"name": name, "path": path,
|
||||
})
|
||||
// 词法路同样去重后重建这条;IDF 统计沿用现有语料(重启时 scanAll 会全量重训)
|
||||
s.lex.Remove(id)
|
||||
s.lex.Insert(id, name+": "+content, s.veczer.Vectorize(text), nil)
|
||||
s.summaries = append(s.summaries, name+" "+content)
|
||||
|
||||
go func() {
|
||||
if err := s.writeIndex(); err != nil {
|
||||
log.Printf("[knowledge] write index error after adding %s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
if err := s.writeIndexLocked(); err != nil {
|
||||
log.Printf("[knowledge] write index error after adding %s: %v", name, err)
|
||||
}
|
||||
log.Printf("[knowledge] added: %s (%d bytes)", name, len(content))
|
||||
return nil
|
||||
}
|
||||
@ -254,11 +341,10 @@ func (s *Store) Remove(name string) error {
|
||||
}
|
||||
delete(s.items, id)
|
||||
s.vec.Remove(id)
|
||||
go func() {
|
||||
if err := s.writeIndex(); err != nil {
|
||||
log.Printf("[knowledge] write index error after removing %s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
s.lex.Remove(id)
|
||||
if err := s.writeIndexLocked(); err != nil {
|
||||
log.Printf("[knowledge] write index error after removing %s: %v", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -288,6 +374,14 @@ func (s *Store) List() []string {
|
||||
func (s *Store) BuildTree() *TreeIndex {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.buildTreeLocked()
|
||||
}
|
||||
|
||||
// buildTreeLocked 与 BuildTree 同义,但**不取锁**——供已持写锁的路径调用。
|
||||
// 为什么需要:writeIndex 会走 BuildTree(RLock),而 Add/Remove 持的是写锁,
|
||||
// 直接调用会死锁;此前就是因此把索引写丢进了无追踪的 goroutine 里,
|
||||
// 结果是「失败只打日志」+ 与调用方(含测试的临时目录清理)竞态。
|
||||
func (s *Store) buildTreeLocked() *TreeIndex {
|
||||
root := newTreeIndex("root")
|
||||
for _, k := range s.items {
|
||||
node := root
|
||||
@ -359,7 +453,14 @@ func (s *Store) SearchTree(query string, topK int) map[string][]*Knowledge {
|
||||
|
||||
// writeIndex 写入 .index.json 树状索引文件(含向量和摘要)
|
||||
func (s *Store) writeIndex() error {
|
||||
tree := s.BuildTree()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.writeIndexLocked()
|
||||
}
|
||||
|
||||
// writeIndexLocked 与 writeIndex 同义但**不取锁**(调用方已持锁)。
|
||||
func (s *Store) writeIndexLocked() error {
|
||||
tree := s.buildTreeLocked()
|
||||
data, err := json.MarshalIndent(tree, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@ -390,11 +491,13 @@ func (s *Store) scanAll() error {
|
||||
s.veczer.Train(s.summaries)
|
||||
}
|
||||
|
||||
s.lex = newLexicalStore()
|
||||
for _, k := range s.items {
|
||||
vec := s.vectorize(k.Name + " " + k.Content)
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
|
||||
text := k.Name + " " + k.Content
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, s.vectorize(text), map[string]string{
|
||||
"name": k.Name, "path": k.Path,
|
||||
})
|
||||
s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -445,5 +548,3 @@ func sanitize(name string) string {
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
return name
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -44,6 +44,51 @@ func TestAddAndSearch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖同名条目必须把旧向量摘掉,而不是再插一份。
|
||||
//
|
||||
// 这条是从一次真实的知识库更新里发现的:在线上实例更新一个已有条目后,
|
||||
// knowledge_count=32 但 vector_count=33 ——多出来的那一条是上一版的副本。
|
||||
// 成因是 vector.Store.Insert 为追加语义(s.docs = append + index.Add),不按 id 去重。
|
||||
// 危害不在于多占一份内存:检索可能命中**已被替换掉的旧内容**。
|
||||
func TestAddOverwriteReplacesVector(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "know_overwrite_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
if err := s.Add("recent", "第一版内容:旧的多模态描述式索引"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.Stats()["vector_count"].(int); got != 1 {
|
||||
t.Fatalf("首次写入后 vector_count 应为 1,实为 %d", got)
|
||||
}
|
||||
|
||||
if err := s.Add("recent", "第二版内容:媒体已成为图记忆的一等节点"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n := s.Stats()["knowledge_count"].(int); n != 1 {
|
||||
t.Fatalf("同名覆盖后 knowledge_count 应为 1,实为 %d", n)
|
||||
}
|
||||
if n := s.Stats()["vector_count"].(int); n != 1 {
|
||||
t.Fatalf("同名覆盖后 vector_count 应为 1(多了就是旧版没被摘掉),实为 %d", n)
|
||||
}
|
||||
|
||||
// 目录里也只应有一份内容,且是新的那份
|
||||
b, err := os.ReadFile(dir + "/recent/content.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != "第二版内容:媒体已成为图记忆的一等节点" {
|
||||
t.Fatalf("content.md 未被新内容覆盖,实为 %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "know_list_*")
|
||||
if err != nil {
|
||||
|
||||
154
internal/knowledge/rankdiag_test.go
Normal file
154
internal/knowledge/rankdiag_test.go
Normal file
@ -0,0 +1,154 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// 知识库检索质量判据(真实数据,默认跳过)。
|
||||
//
|
||||
// KB_DIAG=1 → 跑并打印指标
|
||||
// KB_DIAG=1 KB_DIAG_ASSERT=1 → 额外断言门槛(CI/回归用)
|
||||
// KB_DIAG_ROOT / KB_DIAG_MODELS → 覆盖数据与词向量路径
|
||||
//
|
||||
// 判据选「自检索 top-1 / MRR」的原因:不依赖人工标注问答对,且能直接量出
|
||||
// **区分度**——词向量取平均后所有文档挤在语料均值附近,前两名分差极小,
|
||||
// 排序等于噪声;这一项掉下来就说明检索坏了。
|
||||
//
|
||||
// 实测(33 条真实 KB):
|
||||
//
|
||||
// 修复前(仅稠密路) top-1 5/33 = 15%,MRR 0.271,平均分差 0.0133
|
||||
// 修复后(稠密+词法融合)top-1 7/33 = 21%,MRR 0.376,平均分差 0.1280
|
||||
// 门槛取 MRR ≥ 0.34 且分差 ≥ 0.10(留出余量,只挡「退化回噪声」)
|
||||
func TestRankingQualityOnRealKB(t *testing.T) {
|
||||
if os.Getenv("KB_DIAG") == "" {
|
||||
t.Skip("需要 KB_DIAG=1(真实 KB + 词向量文件)")
|
||||
}
|
||||
srcRoot := envOr("KB_DIAG_ROOT", "/home/newqqagent/knowledge")
|
||||
models := envOr("KB_DIAG_MODELS", "/data/cc.zh.top200k.vec,/data/cc.en.top200k.vec")
|
||||
emb := memory.NewStaticEmbedder(strings.Split(models, ",")...)
|
||||
|
||||
// 拷贝到临时目录跑:Start() 会重写 .index.json,不能动线上数据
|
||||
tmp := t.TempDir()
|
||||
entries, err := os.ReadDir(srcRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s: %v", srcRoot, err)
|
||||
}
|
||||
names := []string{}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(srcRoot, e.Name(), "content.md")
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(tmp, e.Name(), "content.md")
|
||||
os.MkdirAll(filepath.Dir(dst), 0755)
|
||||
out, _ := os.Create(dst)
|
||||
io.Copy(out, in)
|
||||
out.Close()
|
||||
in.Close()
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
if len(names) == 0 {
|
||||
t.Fatal("没有可用的知识条目")
|
||||
}
|
||||
|
||||
st := NewStore(tmp)
|
||||
st.SetVectorizer(emb)
|
||||
if err := st.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
top1, mrr, missed := 0, 0.0, []string{}
|
||||
for _, name := range names {
|
||||
// 取全量排名:MRR 的定义用到真实名次,只取 top-2 会把 rank>2 的全都记 0
|
||||
// (我第一版就是这么写的,把 0.376 误报成 0.197)
|
||||
hits := st.Search(name, len(names))
|
||||
if len(hits) == 0 {
|
||||
missed = append(missed, name+"(无结果)")
|
||||
continue
|
||||
}
|
||||
if hits[0].Name == name {
|
||||
top1++
|
||||
} else {
|
||||
missed = append(missed, fmt.Sprintf("%s→%s", name, hits[0].Name))
|
||||
}
|
||||
for i, h := range hits {
|
||||
if h.Name == name {
|
||||
mrr += 1.0 / float64(i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
n := float64(len(names))
|
||||
rate := 100 * float64(top1) / n
|
||||
fmt.Printf("\n === 知识库检索质量(%d 条,自检索判据)===\n", len(names))
|
||||
fmt.Printf(" top-1 %d/%d = %.0f%% MRR %.3f\n", top1, len(names), rate, mrr/n)
|
||||
if len(missed) > 0 {
|
||||
fmt.Printf(" 未命中 top-1(前 10):%v\n", firstN(missed, 10))
|
||||
}
|
||||
for _, q := range []string{"最近更新", "首启人格门禁", "插件怎么开发和部署", "统一多模态向量空间 ONNX", "隐私政策"} {
|
||||
hits := st.Search(q, 2)
|
||||
got := []string{}
|
||||
for _, h := range hits {
|
||||
got = append(got, h.Name)
|
||||
}
|
||||
fmt.Printf(" 查询「%s」→ %v\n", q, got)
|
||||
}
|
||||
|
||||
if os.Getenv("KB_DIAG_SWEEP") != "" {
|
||||
fmt.Printf("\n === 融合权重扫描(1.0 = 只用稠密路,0.0 = 只用词法路)===\n")
|
||||
saved := densePathWeight
|
||||
for _, w := range []float64{1.0, 0.8, 0.7, 0.5, 0.3, 0.0} {
|
||||
densePathWeight = w
|
||||
t1, m := 0, 0.0
|
||||
for _, name := range names {
|
||||
hits := st.Search(name, len(names))
|
||||
for i, h := range hits {
|
||||
if h.Name == name {
|
||||
if i == 0 {
|
||||
t1++
|
||||
}
|
||||
m += 1.0 / float64(i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(" 权重 %.1f:top-1 %2d/%d = %3.0f%% MRR %.3f\n",
|
||||
w, t1, len(names), 100*float64(t1)/float64(len(names)), m/float64(len(names)))
|
||||
}
|
||||
densePathWeight = saved
|
||||
}
|
||||
|
||||
if os.Getenv("KB_DIAG_ASSERT") != "" {
|
||||
if mrr/n < 0.34 {
|
||||
t.Fatalf("检索质量退化:MRR %.3f < 0.34(修复前 0.271,修复后 0.376)", mrr/n)
|
||||
}
|
||||
if rate < 18 {
|
||||
t.Fatalf("检索质量退化:top-1 %.0f%% < 18%%", rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func firstN(s []string, n int) []string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
176
internal/knowledge/search_fusion_test.go
Normal file
176
internal/knowledge/search_fusion_test.go
Normal file
@ -0,0 +1,176 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
// fakeDense 是可控的稠密向量器:按文本查表,缺省给同一个向量。
|
||||
// 用它把「稠密路无区分度/给空向量」这类真实故障在单测里复现出来。
|
||||
type fakeDense struct {
|
||||
byText map[string]vector.Vector
|
||||
def vector.Vector
|
||||
}
|
||||
|
||||
func (f fakeDense) Vectorize(text string) vector.Vector {
|
||||
if v, ok := f.byText[text]; ok {
|
||||
return v
|
||||
}
|
||||
return f.def
|
||||
}
|
||||
|
||||
// EmbedImage 满足 vector.Vectorizer 接口(本用例只用到文本路)。
|
||||
func (f fakeDense) EmbedImage([]byte, string) (vector.Vector, error) {
|
||||
return f.def, nil
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T, dense vector.Vectorizer) *Store {
|
||||
t.Helper()
|
||||
st := NewStore(t.TempDir())
|
||||
if dense != nil {
|
||||
st.SetVectorizer(dense)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func mustAdd(t *testing.T, st *Store, name, content string) {
|
||||
t.Helper()
|
||||
if err := st.Add(name, content); err != nil {
|
||||
t.Fatalf("add %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func names(hits []*Knowledge) []string {
|
||||
out := make([]string, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// base36 生成互不重复的短串(造独特词的量级要大,不能用 a..z 循环重复)。
|
||||
func base36(n int) string {
|
||||
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
out := ""
|
||||
for n > 0 {
|
||||
out = string(digits[n%36]) + out
|
||||
n /= 36
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 稠密路给**空向量**(真实场景:查询词全在停用词表里,例如"最近更新")时,
|
||||
// 词法路必须把结果救回来——修复前这里直接返回空。
|
||||
func TestSearchLexicalRescuesEmptyDenseQuery(t *testing.T) {
|
||||
dense := fakeDense{def: vector.Vector{"0": 1}}
|
||||
st := newTestStore(t, dense)
|
||||
mustAdd(t, st, "changelog_v1", "最近更新了很多东西 发布说明")
|
||||
mustAdd(t, st, "weather_doc", "天气预报 晴转多云")
|
||||
|
||||
// 让"更新"的稠密向量为空(模拟停用词化)
|
||||
st.SetVectorizer(fakeDense{
|
||||
byText: map[string]vector.Vector{"更新": {}},
|
||||
def: vector.Vector{"0": 1},
|
||||
})
|
||||
hits := st.Search("更新", 5)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("稠密路给空向量时不该返回空结果(词法路应救回来)")
|
||||
}
|
||||
if hits[0].Name != "changelog_v1" {
|
||||
t.Fatalf("应命中含「更新」的条目,实际: %v", names(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// 稠密路对所有文本给**同一个向量**(真实故障:词向量平均后各向异性、区分度极低)时,
|
||||
// 排序必须由词法路决定。
|
||||
func TestSearchLexicalBreaksDenseTies(t *testing.T) {
|
||||
same := vector.Vector{"0": 1, "1": 1}
|
||||
st := newTestStore(t, fakeDense{def: same})
|
||||
mustAdd(t, st, "plugin_dev_build", "插件构建与部署 hmapdev 命令")
|
||||
mustAdd(t, st, "cangjie_manual", "仓颉编程语言知识手册")
|
||||
mustAdd(t, st, "privacy_policy", "隐私政策")
|
||||
|
||||
hits := st.Search("hmapdev 构建", 3)
|
||||
if len(hits) == 0 || hits[0].Name != "plugin_dev_build" {
|
||||
t.Fatalf("稠密路并列时应由词法路选出 plugin_dev_build,实际: %v", names(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// 词法路的候选中选阈值必须是 0:TF-IDF 余弦量级只有 0.0~0.2,沿用稠密路的 0.05
|
||||
// 会把有效候选静默砍掉(真实 KB 实测自检索 MRR 0.307→0.193)。
|
||||
//
|
||||
// 判据分两层,各钉一半:
|
||||
// - **语义层**由 internal/memory/vector 的 TestSearchScoredRespectsMinScore 证明
|
||||
// (同一候选在默认阈值下被过滤、阈值 0 时被召回);
|
||||
// - **接线层**在这里钉住:知识库的词法路用的就是阈值 0 的那个 store。
|
||||
// 不在这里造「低余弦夹具」的原因:分词器会丢掉纯拉丁 token、也会过滤未登录词,
|
||||
// 造出来的夹具余弦根本压不到阈值以下(我先试了两种,余弦 0.23/0.27,
|
||||
// 前提断言直接把这两版夹具否掉了)。
|
||||
func TestLexicalStoreUsesZeroMinScore(t *testing.T) {
|
||||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||||
if got := st.lex.MinScore(); got != 0 {
|
||||
t.Fatalf("词法路阈值必须为 0,实际 %v(沿用稠密路阈值会静默丢候选)", got)
|
||||
}
|
||||
if got := st.vec.MinScore(); got != vector.DefaultMinScore {
|
||||
t.Fatalf("稠密路阈值应保持默认 %v,实际 %v", vector.DefaultMinScore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Add / Remove 必须同时维护两路索引:只维护一路会让被删条目继续被检索命中
|
||||
// (或新条目只在其中一路可见)。
|
||||
func TestAddRemoveKeepsBothPaths(t *testing.T) {
|
||||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||||
mustAdd(t, st, "alpha", "alpha 独有词 alphaonly")
|
||||
mustAdd(t, st, "beta", "beta 独有词 betaonly")
|
||||
|
||||
has := func(q, want string) bool {
|
||||
for _, h := range st.Search(q, 5) {
|
||||
if h.Name == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !has("alphaonly", "alpha") {
|
||||
t.Fatal("新增条目应可被检索到")
|
||||
}
|
||||
if err := st.Remove("alpha"); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if has("alphaonly", "alpha") {
|
||||
t.Fatal("已删除条目仍被检索命中(两路索引有一路没清)")
|
||||
}
|
||||
if !has("betaonly", "beta") {
|
||||
t.Fatal("删除其它条目不应影响 beta")
|
||||
}
|
||||
}
|
||||
|
||||
// 分数相同时必须按名字定序,保证结果可重复(否则同一查询两次结果可能不同)。
|
||||
func TestSearchDeterministicOnTies(t *testing.T) {
|
||||
same := vector.Vector{"0": 1}
|
||||
st := newTestStore(t, fakeDense{def: same})
|
||||
for _, n := range []string{"ccc", "aaa", "bbb"} {
|
||||
mustAdd(t, st, n, "完全一样的内容")
|
||||
}
|
||||
first := names(st.Search("完全一样的内容", 3))
|
||||
for i := 0; i < 5; i++ {
|
||||
got := names(st.Search("完全一样的内容", 3))
|
||||
for j := range first {
|
||||
if got[j] != first[j] {
|
||||
t.Fatalf("结果不确定:第 %d 次 %v != 首次 %v", i, got, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 两路都空时不能 panic,且应返回空。
|
||||
func TestSearchEmptyStore(t *testing.T) {
|
||||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||||
if hits := st.Search("随便", 5); len(hits) != 0 {
|
||||
t.Fatalf("空库应返回空,实际 %v", names(hits))
|
||||
}
|
||||
}
|
||||
@ -7,11 +7,11 @@ import (
|
||||
)
|
||||
|
||||
type bilingualEvent struct {
|
||||
idx int
|
||||
source string
|
||||
topic string
|
||||
text string // cleaned text for vectorization
|
||||
label string // short description
|
||||
idx int
|
||||
source string
|
||||
topic string
|
||||
text string // cleaned text for vectorization
|
||||
label string // short description
|
||||
}
|
||||
|
||||
func TestBilingualPruningAccuracy(t *testing.T) {
|
||||
@ -40,16 +40,16 @@ func TestBilingualPruningAccuracy(t *testing.T) {
|
||||
t.Logf("%s: %d words", cfg.name, len(e.words))
|
||||
|
||||
type scored struct {
|
||||
idx int
|
||||
topic string
|
||||
label string
|
||||
score float64
|
||||
idx int
|
||||
topic string
|
||||
label string
|
||||
score float64
|
||||
}
|
||||
|
||||
queries := []struct {
|
||||
q string
|
||||
qTopic string
|
||||
desc string
|
||||
q string
|
||||
qTopic string
|
||||
desc string
|
||||
}{
|
||||
{"老大说了关于 React 组件的事情", "老大私聊", "中英混合:老大+React"},
|
||||
{"帮我查一下 Nginx 反向代理配置", "服务器运维", "中英混合:Nginx+反向代理"},
|
||||
@ -189,10 +189,10 @@ func TestBilingualVectorizeClean(t *testing.T) {
|
||||
|
||||
func genBilingualEvents() []bilingualEvent {
|
||||
entries := []struct {
|
||||
topic string
|
||||
zh string // Chinese description
|
||||
en string // English terms mixed in
|
||||
source string
|
||||
topic string
|
||||
zh string // Chinese description
|
||||
en string // English terms mixed in
|
||||
source string
|
||||
}{
|
||||
{"大学招生", "河南医药大学录取分数线", "", "qq"},
|
||||
{"大学招生", "医学院专业排名", "medical university ranking", "agent"},
|
||||
|
||||
271
internal/memory/block.go
Normal file
271
internal/memory/block.go
Normal file
@ -0,0 +1,271 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BlockModality 是一等记忆块的原生模态。
|
||||
type BlockModality string
|
||||
|
||||
const (
|
||||
BlockText BlockModality = "text"
|
||||
BlockImage BlockModality = "image"
|
||||
BlockVideo BlockModality = "video"
|
||||
BlockAudio BlockModality = "audio"
|
||||
)
|
||||
|
||||
// MemoryBlock 是 Context、Document、Graph 三层共同使用的记忆块值。
|
||||
//
|
||||
// 它不携带 Layer、Owner 或 RefCount:块当前由哪个层的容器持有,哪个层就是
|
||||
// 唯一事实源。Context→Document→Graph 迁移的是这个值本身,不建立平行保活账本。
|
||||
// PayloadDigest 仅用于定位内容寻址的原始字节,不表示另一条逻辑记忆。
|
||||
type MemoryBlock struct {
|
||||
ID string `json:"id"`
|
||||
Modality BlockModality `json:"modality"`
|
||||
Text string `json:"text,omitempty"`
|
||||
PayloadDigest string `json:"payload_digest,omitempty"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Vector []float64 `json:"vector,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。
|
||||
// source/target kind 当前允许 block、entity、sentence、document。
|
||||
type MemoryBlockEdge struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceKind string `json:"source_kind"`
|
||||
SourceID string `json:"source_id"`
|
||||
TargetKind string `json:"target_kind"`
|
||||
TargetID string `json:"target_id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func validBlockModality(modality BlockModality) bool {
|
||||
return modality == BlockText || modality == BlockImage || modality == BlockVideo || modality == BlockAudio
|
||||
}
|
||||
|
||||
// PutMemoryBlocks 将完成 L2→L3 迁移的块写成 GraphDB 原生节点。
|
||||
// 调用方只有在本事务成功后才能从 Document 删除这些块。
|
||||
func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
|
||||
if len(blocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, block := range blocks {
|
||||
if block.ID == "" {
|
||||
return fmt.Errorf("memory block id is required")
|
||||
}
|
||||
if !validBlockModality(block.Modality) {
|
||||
return fmt.Errorf("memory block %s has invalid modality %q", block.ID, block.Modality)
|
||||
}
|
||||
vectorJSON, err := json.Marshal(block.Vector)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal memory block %s vector: %w", block.ID, err)
|
||||
}
|
||||
now := time.Now()
|
||||
if block.CreatedAt.IsZero() {
|
||||
block.CreatedAt = now
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO memory_blocks (
|
||||
id, modality, text_content, payload_digest, mime, size, width, height,
|
||||
vector, fingerprint, source, tool, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
modality = excluded.modality,
|
||||
text_content = excluded.text_content,
|
||||
payload_digest = excluded.payload_digest,
|
||||
mime = excluded.mime,
|
||||
size = excluded.size,
|
||||
width = excluded.width,
|
||||
height = excluded.height,
|
||||
vector = excluded.vector,
|
||||
fingerprint = excluded.fingerprint,
|
||||
source = excluded.source,
|
||||
tool = excluded.tool,
|
||||
updated_at = excluded.updated_at`,
|
||||
block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME,
|
||||
block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
|
||||
block.Source, block.Tool, block.CreatedAt, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("put memory block %s: %w", block.ID, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// PutDocumentNode 在 L3 登记一个文档节点,作为 document --contains--> block
|
||||
// 结构边的端点。文档正文已蒸馏为实体/关系,这里只保留身份与摘要。
|
||||
func (g *GraphDB) PutDocumentNode(id, summary string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("document node id is required")
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
_, err := g.db.Exec(`INSERT INTO documents (id, summary) VALUES (?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET summary = excluded.summary`, id, summary)
|
||||
return err
|
||||
}
|
||||
|
||||
// MemoryBlocks 查询 Graph 层实际持有的一等记忆节点。
|
||||
func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT id, modality, text_content, payload_digest, mime,
|
||||
size, width, height, vector, fingerprint, source, tool, created_at, updated_at
|
||||
FROM memory_blocks ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var blocks []MemoryBlock
|
||||
for rows.Next() {
|
||||
var block MemoryBlock
|
||||
var vectorJSON string
|
||||
if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
|
||||
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
|
||||
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
|
||||
&block.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vectorJSON != "" && vectorJSON != "null" {
|
||||
if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil {
|
||||
return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err)
|
||||
}
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
return blocks, rows.Err()
|
||||
}
|
||||
|
||||
func validGraphNodeKind(kind string) bool {
|
||||
return kind == "block" || kind == "entity" || kind == "sentence" || kind == "document"
|
||||
}
|
||||
|
||||
func graphNodeExists(tx *sql.Tx, kind, id string) (bool, error) {
|
||||
var n int
|
||||
var err error
|
||||
switch kind {
|
||||
case "block":
|
||||
err = tx.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, id).Scan(&n)
|
||||
case "entity":
|
||||
err = tx.QueryRow(`SELECT COUNT(*) FROM entities WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
|
||||
case "sentence":
|
||||
err = tx.QueryRow(`SELECT COUNT(*) FROM sentences WHERE CAST(id AS TEXT) = ?`, id).Scan(&n)
|
||||
case "document":
|
||||
err = tx.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, id).Scan(&n)
|
||||
default:
|
||||
return false, fmt.Errorf("invalid graph node kind %q", kind)
|
||||
}
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
// AddMemoryBlockEdge 建立 contains、depicts、derived_from 等原生图边。
|
||||
// 端点必须是真实 Graph 节点,不能用 owner 字符串伪装关系。
|
||||
func (g *GraphDB) AddMemoryBlockEdge(sourceKind, sourceID, targetKind, targetID, edgeType string) error {
|
||||
if !validGraphNodeKind(sourceKind) || !validGraphNodeKind(targetKind) {
|
||||
return fmt.Errorf("invalid memory block edge kinds %q -> %q", sourceKind, targetKind)
|
||||
}
|
||||
if sourceID == "" || targetID == "" || edgeType == "" {
|
||||
return fmt.Errorf("memory block edge endpoints and type are required")
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, endpoint := range []struct{ kind, id string }{{sourceKind, sourceID}, {targetKind, targetID}} {
|
||||
exists, err := graphNodeExists(tx, endpoint.kind, endpoint.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%s graph node %s does not exist", endpoint.kind, endpoint.id)
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(`INSERT OR IGNORE INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type)
|
||||
VALUES (?, ?, ?, ?, ?)`, sourceKind, sourceID, targetKind, targetID, edgeType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT id, source_kind, source_id, target_kind, target_id,
|
||||
edge_type, created_at FROM memory_block_edges ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var edges []MemoryBlockEdge
|
||||
for rows.Next() {
|
||||
var edge MemoryBlockEdge
|
||||
if err := rows.Scan(&edge.ID, &edge.SourceKind, &edge.SourceID, &edge.TargetKind,
|
||||
&edge.TargetID, &edge.Type, &edge.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edges = append(edges, edge)
|
||||
}
|
||||
return edges, rows.Err()
|
||||
}
|
||||
|
||||
// BlocksForNode 返回与某个图节点通过任意边相连的一等记忆块。
|
||||
// 例:sentence --contains--> block;entity --depicts--> block。
|
||||
func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT b.id, b.modality, b.text_content, b.payload_digest,
|
||||
b.mime, b.size, b.width, b.height, b.vector, b.fingerprint, b.source, b.tool,
|
||||
b.created_at, b.updated_at
|
||||
FROM memory_block_edges e
|
||||
JOIN memory_blocks b ON (
|
||||
(e.source_kind = 'block' AND e.source_id = b.id AND e.target_kind = ? AND e.target_id = ?)
|
||||
OR (e.target_kind = 'block' AND e.target_id = b.id AND e.source_kind = ? AND e.source_id = ?))
|
||||
ORDER BY b.created_at, b.id`, nodeKind, nodeID, nodeKind, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var blocks []MemoryBlock
|
||||
for rows.Next() {
|
||||
var block MemoryBlock
|
||||
var vectorJSON string
|
||||
if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
|
||||
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
|
||||
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
|
||||
&block.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vectorJSON != "" && vectorJSON != "null" {
|
||||
if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil {
|
||||
return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err)
|
||||
}
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
return blocks, rows.Err()
|
||||
}
|
||||
@ -28,13 +28,13 @@ func cleanQQTemplate(text string) string {
|
||||
}
|
||||
|
||||
type cleanTestEvent struct {
|
||||
idx int
|
||||
source string
|
||||
input string
|
||||
response string
|
||||
rawText string
|
||||
idx int
|
||||
source string
|
||||
input string
|
||||
response string
|
||||
rawText string
|
||||
cleanedText string
|
||||
topic string
|
||||
topic string
|
||||
}
|
||||
|
||||
func TestCleanStressPrecision(t *testing.T) {
|
||||
@ -59,53 +59,53 @@ func TestCleanStressPrecision(t *testing.T) {
|
||||
}
|
||||
t.Logf("topics: %v, events: %d", usedTopics, len(events))
|
||||
|
||||
for _, qTopic := range usedTopics {
|
||||
query := queryForTopic(qTopic)
|
||||
qVec := e.Vectorize(query)
|
||||
for _, qTopic := range usedTopics {
|
||||
query := queryForTopic(qTopic)
|
||||
qVec := e.Vectorize(query)
|
||||
|
||||
type scored struct {
|
||||
idx int
|
||||
topic string
|
||||
text string
|
||||
score float64
|
||||
}
|
||||
all := make([]scored, len(events))
|
||||
for i, ev := range events {
|
||||
text := ev.rawText
|
||||
if cleanMode {
|
||||
text = ev.cleanedText
|
||||
type scored struct {
|
||||
idx int
|
||||
topic string
|
||||
text string
|
||||
score float64
|
||||
}
|
||||
all := make([]scored, len(events))
|
||||
for i, ev := range events {
|
||||
text := ev.rawText
|
||||
if cleanMode {
|
||||
text = ev.cleanedText
|
||||
}
|
||||
vec := e.Vectorize(text)
|
||||
all[i] = scored{idx: i, topic: ev.topic, text: text, score: cosineSim(qVec, vec)}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].score > all[j].score })
|
||||
|
||||
topK := len(usedTopics) * 2
|
||||
if topK > len(all) {
|
||||
topK = len(all)
|
||||
}
|
||||
|
||||
intraHits := 0
|
||||
for _, s := range all[:topK] {
|
||||
if s.topic == qTopic {
|
||||
intraHits++
|
||||
}
|
||||
}
|
||||
expected := countTopicEvents(events, qTopic)
|
||||
if expected > topK {
|
||||
expected = topK
|
||||
}
|
||||
recall := float64(intraHits) / float64(expected)
|
||||
|
||||
if recall < 0.3 {
|
||||
t.Logf(" [LOW] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
|
||||
for _, s := range all[:8] {
|
||||
t.Logf(" [%.4f] %s", s.score, trimLen(s.text, 60))
|
||||
}
|
||||
} else {
|
||||
t.Logf(" [OK] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
|
||||
}
|
||||
}
|
||||
vec := e.Vectorize(text)
|
||||
all[i] = scored{idx: i, topic: ev.topic, text: text, score: cosineSim(qVec, vec)}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].score > all[j].score })
|
||||
|
||||
topK := len(usedTopics) * 2
|
||||
if topK > len(all) {
|
||||
topK = len(all)
|
||||
}
|
||||
|
||||
intraHits := 0
|
||||
for _, s := range all[:topK] {
|
||||
if s.topic == qTopic {
|
||||
intraHits++
|
||||
}
|
||||
}
|
||||
expected := countTopicEvents(events, qTopic)
|
||||
if expected > topK {
|
||||
expected = topK
|
||||
}
|
||||
recall := float64(intraHits) / float64(expected)
|
||||
|
||||
if recall < 0.3 {
|
||||
t.Logf(" [LOW] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
|
||||
for _, s := range all[:8] {
|
||||
t.Logf(" [%.4f] %s", s.score, trimLen(s.text, 60))
|
||||
}
|
||||
} else {
|
||||
t.Logf(" [OK] query=%q topK=%d intra=%d/%d recall=%.2f", qTopic, topK, intraHits, expected, recall)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -252,11 +252,11 @@ func genStressEvents(n int) []cleanTestEvent {
|
||||
keywords []string
|
||||
sources []string
|
||||
}{
|
||||
"大学招生": {[]string{"河南医药大学", "录取分数线", "专业排名", "高考志愿", "招生简章"}, []string{"qq", "qq", "agent"}},
|
||||
"老大私聊": {[]string{"老大私聊消息", "回复老大", "任务安排", "汇报工作", "收到"}, []string{"qq", "agent", "agent"}},
|
||||
"前端开发": {[]string{"前端组件封装", "页面路由配置", "界面布局设计", "交互逻辑开发", "代码调试优化"}, []string{"cli", "cli", "agent"}},
|
||||
"大学招生": {[]string{"河南医药大学", "录取分数线", "专业排名", "高考志愿", "招生简章"}, []string{"qq", "qq", "agent"}},
|
||||
"老大私聊": {[]string{"老大私聊消息", "回复老大", "任务安排", "汇报工作", "收到"}, []string{"qq", "agent", "agent"}},
|
||||
"前端开发": {[]string{"前端组件封装", "页面路由配置", "界面布局设计", "交互逻辑开发", "代码调试优化"}, []string{"cli", "cli", "agent"}},
|
||||
"服务器运维": {[]string{"反向代理配置", "容器部署方案", "证书续期", "数据库备份恢复", "监控告警处理"}, []string{"cli", "agent", "agent"}},
|
||||
"股票基金": {[]string{"基金定投策略", "股票涨跌分析", "理财收益计算", "市场行情分析", "投资风险管理"}, []string{"qq", "qq", "agent"}},
|
||||
"股票基金": {[]string{"基金定投策略", "股票涨跌分析", "理财收益计算", "市场行情分析", "投资风险管理"}, []string{"qq", "qq", "agent"}},
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
@ -292,13 +292,13 @@ func genStressEvents(n int) []cleanTestEvent {
|
||||
cleaned := cleanEventText(src, input, response)
|
||||
raw := rawEventText(src, input, response)
|
||||
events = append(events, cleanTestEvent{
|
||||
idx: i,
|
||||
source: src,
|
||||
input: input,
|
||||
response: response,
|
||||
rawText: raw,
|
||||
idx: i,
|
||||
source: src,
|
||||
input: input,
|
||||
response: response,
|
||||
rawText: raw,
|
||||
cleanedText: cleaned,
|
||||
topic: tp,
|
||||
topic: tp,
|
||||
})
|
||||
}
|
||||
return events
|
||||
|
||||
@ -13,26 +13,26 @@ import (
|
||||
|
||||
// contentPOS 有实义的词性标签:只保留名词/动词/形容词/专名等
|
||||
var contentPOS = map[string]bool{
|
||||
"n": true, // 普通名词
|
||||
"nr": true, // 人名
|
||||
"ns": true, // 地名
|
||||
"nt": true, // 机构名
|
||||
"nw": true, // 作品名/URL
|
||||
"nz": true, // 其他专名
|
||||
"v": true, // 动词
|
||||
"vd": true, // 副动词
|
||||
"vn": true, // 名动词
|
||||
"a": true, // 形容词
|
||||
"ad": true, // 副形词
|
||||
"an": true, // 名形词
|
||||
"i": true, // 成语
|
||||
"l": true, // 习用语
|
||||
"j": true, // 简称
|
||||
"s": true, // 处所词
|
||||
"f": true, // 方位词
|
||||
"b": true, // 区别词
|
||||
"z": true, // 状态词
|
||||
"t": true, // 时间词
|
||||
"n": true, // 普通名词
|
||||
"nr": true, // 人名
|
||||
"ns": true, // 地名
|
||||
"nt": true, // 机构名
|
||||
"nw": true, // 作品名/URL
|
||||
"nz": true, // 其他专名
|
||||
"v": true, // 动词
|
||||
"vd": true, // 副动词
|
||||
"vn": true, // 名动词
|
||||
"a": true, // 形容词
|
||||
"ad": true, // 副形词
|
||||
"an": true, // 名形词
|
||||
"i": true, // 成语
|
||||
"l": true, // 习用语
|
||||
"j": true, // 简称
|
||||
"s": true, // 处所词
|
||||
"f": true, // 方位词
|
||||
"b": true, // 区别词
|
||||
"z": true, // 状态词
|
||||
"t": true, // 时间词
|
||||
"eng": true, // 英文
|
||||
"x": true, // 非语素字
|
||||
"zg": true, // 其他
|
||||
@ -53,7 +53,7 @@ func GetJieba() *gojieba.Jieba {
|
||||
}()
|
||||
d := jiebaDictDir()
|
||||
if d == "" {
|
||||
log.Printf("[jieba] no dictionary directory found, jieba disabled")
|
||||
log.Printf("[jieba] 未找到词库目录(内嵌落盘失败且模块缓存也不存在),jieba disabled")
|
||||
return
|
||||
}
|
||||
jiebaInst = gojieba.NewJieba(
|
||||
@ -68,9 +68,24 @@ func GetJieba() *gojieba.Jieba {
|
||||
}
|
||||
|
||||
func jiebaDictDir() string {
|
||||
// 首选内嵌词库:它是产物的一部分,与二进制同版本、不依赖宿主环境。
|
||||
//
|
||||
// 以前这里只猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块缓存,
|
||||
// 于是分词与关键词提取会**静默退回空列表**(详见 jieba_embed.go 的说明)。
|
||||
if dir, err := materializeJiebaDict(); err == nil && dir != "" {
|
||||
return dir
|
||||
}
|
||||
log.Printf("[jieba] 内嵌词库落盘失败,回退到模块缓存查找(内嵌失败通常意味着缓存目录不可写)")
|
||||
|
||||
// 回退:开发机上存在的模块缓存(仅作为兵底,不应依赖它)。
|
||||
//
|
||||
// GOMODCACHE is typically $GOPATH/pkg/mod. When set, Go writes modules
|
||||
// under <GOMODCACHE>/github.com/... . Look first at GOMODCACHE, then
|
||||
// derive from GOPATH, then try common locations.
|
||||
return jiebaDictDirFromModuleCache()
|
||||
}
|
||||
|
||||
func jiebaDictDirFromModuleCache() string {
|
||||
candidates := []string{
|
||||
os.Getenv("GOMODCACHE"),
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@ -13,6 +14,7 @@ import (
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tfidf"
|
||||
)
|
||||
|
||||
// ChannelCleaner 按事件来源查找输入通道的 Cleaner 函数。
|
||||
@ -21,63 +23,77 @@ type ChannelCleaner func(source string) func(string) string
|
||||
|
||||
// Doc — 记忆文档:由上下文提炼而来
|
||||
type Doc struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
Entities []string `json:"entities"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Source string `json:"source"` // context / graph / manual
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
AccessCount int `json:"access_count"` // 访问次数
|
||||
LastAccess time.Time `json:"last_access"` // 最后访问时间
|
||||
Vector vector.Vector `json:"vector,omitempty"` // 预计算向量(与 context 同空间),nil 则用 TF-IDF 兜底
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
Entities []string `json:"entities"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Source string `json:"source"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
AccessCount int `json:"access_count"`
|
||||
LastAccess time.Time `json:"last_access"`
|
||||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
||||
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
|
||||
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径)
|
||||
DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算
|
||||
}
|
||||
|
||||
// Store — 文档记忆存储,包含向量索引
|
||||
// Store — 文档记忆存储。
|
||||
// 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。
|
||||
// Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。
|
||||
type Store struct {
|
||||
dir string
|
||||
vec *vector.Store
|
||||
veczer *vector.TFIDFVectorizer
|
||||
mu sync.RWMutex
|
||||
|
||||
docs map[string]*Doc
|
||||
summaries []string // 用于训练向量化器,最大 10000 条
|
||||
vectorizer vector.Vectorizer // 可选:与 context 同空间的向量化器
|
||||
|
||||
dir string
|
||||
mu sync.RWMutex
|
||||
docs map[string]*Doc
|
||||
dirty bool
|
||||
}
|
||||
|
||||
func (s *Store) SetVectorizer(v vector.Vectorizer) {
|
||||
s.vectorizer = v
|
||||
}
|
||||
// fallback 路径(仅稠密空间不可用时加载)
|
||||
tfidfEmb *tfidf.Embedder
|
||||
tfidfIdx *tfidf.SearchableIndex
|
||||
trainTexts []string // 缓存训练文本,延迟训练
|
||||
tfidfOnce sync.Once
|
||||
|
||||
// ReindexWithVectorizer 用给定的向量化器重建所有文档的向量索引
|
||||
func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
log.Printf("[document memory] reindex with vectorizer (%d docs)", len(s.docs))
|
||||
s.vec = vector.NewStore()
|
||||
for _, doc := range s.docs {
|
||||
doc.Vector = v.Vectorize(doc.Summary + " " + doc.Content)
|
||||
s.vec.Insert(doc.ID, doc.Summary, doc.Vector, doc.Meta)
|
||||
}
|
||||
log.Printf("[document memory] reindex with vectorizer complete (%d vectors)", s.vec.Size())
|
||||
// 主路径
|
||||
denseSpace vector.MultimodalEmbedder
|
||||
}
|
||||
|
||||
const maxSummaries = 10000
|
||||
|
||||
func NewStore(dir string) *Store {
|
||||
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),核心不直接依赖分词库。
|
||||
func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store {
|
||||
return &Store{
|
||||
dir: dir,
|
||||
vec: vector.NewStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords),
|
||||
docs: make(map[string]*Doc),
|
||||
dir: dir,
|
||||
docs: make(map[string]*Doc),
|
||||
// tfidf 延迟初始化:只在需要 fallback 时创建
|
||||
tfidfEmb: tfidf.NewEmbedder(tokenizer, 4096),
|
||||
}
|
||||
}
|
||||
|
||||
// ensureTFIDF 延迟初始化 TF-IDF 索引(仅 fallback 路径)。
|
||||
// 调用方已持有 s.mu。
|
||||
func (s *Store) ensureTFIDF() {
|
||||
s.tfidfOnce.Do(func() {
|
||||
s.tfidfIdx = tfidf.NewSearchableIndex(s.tfidfEmb)
|
||||
// 延迟训练:用缓存的文本建立索引
|
||||
texts := make(map[string]string, len(s.trainTexts)/2)
|
||||
for i := 0; i+1 < len(s.trainTexts); i += 2 {
|
||||
texts[s.trainTexts[i]] = s.trainTexts[i+1]
|
||||
}
|
||||
s.tfidfIdx.Train(texts)
|
||||
s.trainTexts = nil // 释放缓存
|
||||
s.tfidfEmb.Train(func() []string {
|
||||
out := make([]string, 0, len(texts))
|
||||
for _, t := range texts {
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}())
|
||||
log.Printf("[document memory] tfidf fallback loaded: %d docs", len(texts))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) Start() error {
|
||||
if err := os.MkdirAll(s.dir, 0755); err != nil {
|
||||
return fmt.Errorf("document store dir: %w", err)
|
||||
@ -85,15 +101,95 @@ func (s *Store) Start() error {
|
||||
if err := s.loadAll(); err != nil {
|
||||
log.Printf("[document memory] load error: %v", err)
|
||||
}
|
||||
log.Printf("[document memory] started with %d docs, %d vectors", len(s.docs), s.vec.Size())
|
||||
log.Printf("[document memory] started with %d docs", len(s.docs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Stop() {
|
||||
s.flush()
|
||||
func (s *Store) Stop() { s.flush() }
|
||||
|
||||
// SetDenseSpace 设置稠密多模态向量空间(主路径)。
|
||||
func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.denseSpace = ds
|
||||
}
|
||||
|
||||
// BuildDenseIndex 为所有文档计算稠密向量(文本 ⊕ 媒体块)。
|
||||
func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
if ds == nil || !ds.Loaded() {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim())
|
||||
count := 0
|
||||
for _, doc := range s.docs {
|
||||
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == ds.Fingerprint() {
|
||||
continue
|
||||
}
|
||||
vec := s.denseFor(doc)
|
||||
if vec == nil {
|
||||
continue
|
||||
}
|
||||
doc.DenseVec = vec
|
||||
doc.DenseFP = ds.Fingerprint()
|
||||
count++
|
||||
}
|
||||
if count > 0 {
|
||||
// 迁移结果必须落盘。否则磁盘上的 DenseFP 永远对不上当前空间,
|
||||
// 判定条件永远成立:每次启动都重算同一批文档,磁盘状态永不收敛。
|
||||
// 迁移是一次性的昂贵操作(实测 200 篇约 7s),所以当场写盘,
|
||||
// 而不是只依赖关停时的 flush——被 kill -9 也不会白算。
|
||||
s.dirty = true
|
||||
s.flushLocked()
|
||||
}
|
||||
log.Printf("[document memory] dense index built: %d new vectors", count)
|
||||
}
|
||||
|
||||
// denseFor 计算文档的稠密向量:文本向量与其一等记忆块的媒体向量融合。
|
||||
//
|
||||
// 只有与当前统一空间同指纹的块向量才参与融合:不同模型/维度的旧向量
|
||||
// 属于另一个坐标系,混进去会算出一个两边都不像的方向。
|
||||
// 任意一路缺失时退化为另一路;都不可用返回 nil。
|
||||
func (s *Store) denseFor(doc *Doc) []float64 {
|
||||
if s.denseSpace == nil || !s.denseSpace.Loaded() {
|
||||
return nil
|
||||
}
|
||||
fp := s.denseSpace.Fingerprint()
|
||||
dim := s.denseSpace.Dim()
|
||||
var parts [][]float64
|
||||
if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil && len(tv) > 0 {
|
||||
parts = append(parts, tv)
|
||||
}
|
||||
for _, b := range doc.Blocks {
|
||||
// 只比指纹不够:指纹相同但**维度不同**的块会被 FuseVectors 按
|
||||
// 「最大维度」拼成错维度向量(并覆盖掉文本向量),而结果又被
|
||||
// 标上当前指纹——于是该文档在检索侧被长度守卫永久跳过,
|
||||
// 且每次启动都会重算。维度不符的块一律不参与融合。
|
||||
if len(b.Vector) == dim && b.Fingerprint == fp {
|
||||
parts = append(parts, b.Vector)
|
||||
}
|
||||
}
|
||||
return vector.FuseVectors(parts...)
|
||||
}
|
||||
|
||||
// Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。
|
||||
func (s *Store) Reindex() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.tfidfOnce = sync.Once{} // 重置延迟初始化
|
||||
texts := make(map[string]string, len(s.docs))
|
||||
for _, doc := range s.docs {
|
||||
texts[doc.ID] = doc.Summary + " " + doc.Content
|
||||
}
|
||||
// 缓存文本供 ensureTFIDF 延迟训练
|
||||
s.trainTexts = make([]string, 0, len(texts)*2)
|
||||
for id, t := range texts {
|
||||
s.trainTexts = append(s.trainTexts, id, t)
|
||||
}
|
||||
s.ensureTFIDF()
|
||||
}
|
||||
|
||||
// Insert 创建/更新文档
|
||||
func (s *Store) Insert(doc *Doc) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@ -107,36 +203,35 @@ func (s *Store) Insert(doc *Doc) error {
|
||||
if doc.AccessCount == 0 {
|
||||
doc.AccessCount = 1
|
||||
}
|
||||
|
||||
s.docs[doc.ID] = doc
|
||||
|
||||
// 增量训练向量化器并加入向量索引
|
||||
s.addSummary(doc.Summary)
|
||||
vec := doc.Vector
|
||||
if vec == nil {
|
||||
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
|
||||
}
|
||||
s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta)
|
||||
text := doc.Summary + " " + doc.Content
|
||||
|
||||
// 主路径:稠密向量(文本 ⊕ 媒体块)
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
|
||||
doc.DenseVec = s.denseFor(doc)
|
||||
doc.DenseFP = s.denseSpace.Fingerprint()
|
||||
}
|
||||
|
||||
// Fallback 路径:缓存文本,延迟训练
|
||||
if s.tfidfIdx != nil {
|
||||
s.tfidfIdx.Add(doc.ID, text)
|
||||
} else {
|
||||
s.trainTexts = append(s.trainTexts, doc.ID, text)
|
||||
}
|
||||
|
||||
// 立即写盘
|
||||
path := filepath.Join(s.dir, doc.ID+".json")
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(path, data, 0644)
|
||||
|
||||
s.dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContextToDoc — 将一段上下文对话历史提炼为文档(带内容去重)
|
||||
// cleanFn 可选,在计算层前统一过滤文本,不影响原文存储。
|
||||
// toolCleanFn 可选,func(name, output string) string,按工具名对输出进行过滤/清洗:
|
||||
// - 返回 "" → 跳过该工具输出(NoMemory)
|
||||
// - 返回清洗后文本 → 用于计算层(Cleaner),原文不受影响
|
||||
func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) {
|
||||
// ContextToDoc 将上下文对话历史提炼为文档。
|
||||
func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{}, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) {
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if cleanFn == nil {
|
||||
cleanFn = func(text string) string { return text }
|
||||
}
|
||||
@ -154,14 +249,13 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.V
|
||||
}
|
||||
content := strings.Join(parts, "\n")
|
||||
contentHash := simpleHash(content)
|
||||
|
||||
summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// 去重
|
||||
for _, d := range s.docs {
|
||||
if d.Meta != nil && d.Meta["content_hash"] == contentHash {
|
||||
d.UpdatedAt = time.Now()
|
||||
@ -171,66 +265,73 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.V
|
||||
d.Summary = summary
|
||||
d.Tags = tags
|
||||
d.Entities = entities
|
||||
d.Blocks = blocksFromEntries(entries)
|
||||
d.DenseVec = s.denseFor(d)
|
||||
if s.denseSpace != nil {
|
||||
d.DenseFP = s.denseSpace.Fingerprint()
|
||||
}
|
||||
s.dirty = true
|
||||
s.mu.Unlock()
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("doc_%d", time.Now().UnixNano())
|
||||
var docVec vector.Vector
|
||||
if vec != nil {
|
||||
docVec = vec.Vectorize(summary + " " + content)
|
||||
} else {
|
||||
docVec = s.veczer.Vectorize(summary + " " + content)
|
||||
}
|
||||
meta := map[string]string{"content_hash": contentHash}
|
||||
meta := map[string]string{"content_hash": contentHash}
|
||||
if source == "context_archived" {
|
||||
meta["is_archived_context"] = "true"
|
||||
}
|
||||
doc := &Doc{
|
||||
ID: id,
|
||||
Summary: summary,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
Entities: entities,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
LastAccess: time.Now(),
|
||||
AccessCount: 1,
|
||||
Source: source,
|
||||
Meta: meta,
|
||||
Vector: docVec,
|
||||
ID: id, Summary: summary, Content: content, Tags: tags,
|
||||
Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta,
|
||||
Blocks: blocksFromEntries(entries),
|
||||
}
|
||||
s.docs[id] = doc
|
||||
doc.DenseVec = s.denseFor(doc)
|
||||
if s.denseSpace != nil {
|
||||
doc.DenseFP = s.denseSpace.Fingerprint()
|
||||
}
|
||||
text := summary + " " + content
|
||||
if s.tfidfIdx != nil {
|
||||
s.tfidfIdx.Add(id, text)
|
||||
} else {
|
||||
s.trainTexts = append(s.trainTexts, id, text)
|
||||
}
|
||||
|
||||
// 加入向量索引
|
||||
s.addSummary(summary)
|
||||
s.vec.Insert(id, summary, doc.Vector, nil)
|
||||
|
||||
s.dirty = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// 立即写盘
|
||||
path := filepath.Join(s.dir, id+".json")
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(path, data, 0644)
|
||||
|
||||
s.dirty = true
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// Consume — 向量相似度查询并移除文档(召回后即从冷存储删除,避免重复记忆)
|
||||
// Consume 向量相似度查询并移除文档
|
||||
func (s *Store) Consume(text string, topK int) []*Doc {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
|
||||
vec := s.vectorizeQuery(text)
|
||||
results := s.vec.Search(vec, topK)
|
||||
// 主路径:稠密检索
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() {
|
||||
if qv, err := s.denseSpace.VectorizeDense(text); err == nil {
|
||||
results := s.denseSearchScored(qv, topK)
|
||||
var docs []*Doc
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.Doc.ID]; ok {
|
||||
s.removeDoc(r.Doc.ID)
|
||||
s.dirty = true
|
||||
docs = append(docs, d)
|
||||
}
|
||||
}
|
||||
return docs
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback:TF-IDF 倒排检索(延迟初始化)
|
||||
s.ensureTFIDF()
|
||||
results := s.tfidfIdx.Search(text, topK)
|
||||
var docs []*Doc
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.ID]; ok {
|
||||
@ -242,75 +343,124 @@ func (s *Store) Consume(text string, topK int) []*Doc {
|
||||
return docs
|
||||
}
|
||||
|
||||
// vectorizeQuery 用语义向量化器(首选)或 TF-IDF(兜底)处理查询文本
|
||||
func (s *Store) vectorizeQuery(text string) vector.Vector {
|
||||
if s.vectorizer != nil {
|
||||
return s.vectorizer.Vectorize(text)
|
||||
func (s *Store) Query(text string, topK int) []*Doc {
|
||||
hits := s.QueryScored(text, topK)
|
||||
out := make([]*Doc, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Doc
|
||||
}
|
||||
return s.veczer.Vectorize(text)
|
||||
return out
|
||||
}
|
||||
|
||||
// Query — 向量相似度查询文档
|
||||
func (s *Store) Query(text string, topK int) []*Doc {
|
||||
// DocHit 是一篇文档记忆的相似度候选及原始分数。
|
||||
type DocHit struct {
|
||||
Doc *Doc
|
||||
Score float64
|
||||
}
|
||||
|
||||
func (s *Store) QueryScored(text string, topK int) []DocHit {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
|
||||
vec := s.vectorizeQuery(text)
|
||||
results := s.vec.Search(vec, topK)
|
||||
// 主路径
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() {
|
||||
if qv, err := s.denseSpace.VectorizeDense(text); err == nil {
|
||||
results := s.denseSearchScored(qv, topK)
|
||||
for i := range results {
|
||||
if d, ok := s.docs[results[i].Doc.ID]; ok {
|
||||
d.AccessCount++
|
||||
d.LastAccess = time.Now()
|
||||
results[i].Doc = d
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
var docs []*Doc
|
||||
// Fallback(需要写锁来 ensureTFIDF)
|
||||
s.mu.RUnlock()
|
||||
s.mu.Lock()
|
||||
s.ensureTFIDF()
|
||||
s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
|
||||
results := s.tfidfIdx.Search(text, topK)
|
||||
var out []DocHit
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.ID]; ok {
|
||||
d.AccessCount++
|
||||
d.LastAccess = time.Now()
|
||||
docs = append(docs, d)
|
||||
out = append(out, DocHit{Doc: d, Score: r.Score})
|
||||
}
|
||||
}
|
||||
return docs
|
||||
return out
|
||||
}
|
||||
|
||||
// Reindex — 重新训练并重建向量索引
|
||||
func (s *Store) Reindex() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
log.Printf("[document memory] reindexing %d docs", len(s.docs))
|
||||
|
||||
s.veczer.Train(s.summaries)
|
||||
|
||||
s.vec = vector.NewStore()
|
||||
for _, doc := range s.docs {
|
||||
vec := doc.Vector
|
||||
if vec == nil {
|
||||
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
|
||||
}
|
||||
s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta)
|
||||
func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
|
||||
if len(queryVec) == 0 {
|
||||
return nil
|
||||
}
|
||||
type scored struct {
|
||||
did string
|
||||
score float64
|
||||
}
|
||||
var results []scored
|
||||
for _, doc := range s.docs {
|
||||
if len(doc.DenseVec) != len(queryVec) {
|
||||
continue
|
||||
}
|
||||
score := denseCosine(queryVec, doc.DenseVec)
|
||||
if score > 0.01 {
|
||||
results = append(results, scored{doc.ID, score})
|
||||
}
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(results, func(i, j int) bool { return results[i].score > results[j].score })
|
||||
if len(results) > topK {
|
||||
results = results[:topK]
|
||||
}
|
||||
out := make([]DocHit, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = DocHit{Doc: s.docs[r.did], Score: r.score}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
log.Printf("[document memory] reindex complete (%d vectors)", s.vec.Size())
|
||||
func denseCosine(a, b []float64) float64 {
|
||||
var dot, na, nb float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
na += a[i] * a[i]
|
||||
nb += b[i] * b[i]
|
||||
}
|
||||
if na == 0 || nb == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / math.Sqrt(na*nb)
|
||||
}
|
||||
|
||||
func (s *Store) Stats() map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
idxSize := 0
|
||||
if s.tfidfIdx != nil {
|
||||
idxSize = s.tfidfIdx.Size()
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"doc_count": len(s.docs),
|
||||
"vector_count": s.vec.Size(),
|
||||
"summary_count": len(s.summaries),
|
||||
"dir": s.dir,
|
||||
"doc_count": len(s.docs),
|
||||
"index_count": idxSize,
|
||||
"dir": s.dir,
|
||||
}
|
||||
}
|
||||
|
||||
// FindColdDocs — 查找冷文档:超过 maxAge 未访问且访问次数 <= minAccess
|
||||
func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
cutoff := time.Now().Add(-maxAge)
|
||||
var cold []*Doc
|
||||
for _, d := range s.docs {
|
||||
@ -321,60 +471,53 @@ func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc {
|
||||
return cold
|
||||
}
|
||||
|
||||
// Get 返回指定文档(不存在时为 nil)。
|
||||
func (s *Store) Get(id string) *Doc {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.docs[id]
|
||||
}
|
||||
|
||||
// Blocks 返回全部文档持有的一等记忆块(供跨层存活判定)。
|
||||
func (s *Store) Blocks() []memory.MemoryBlock {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out []memory.MemoryBlock
|
||||
for _, d := range s.docs {
|
||||
out = append(out, d.Blocks...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) RecentDocs(n int) []*Doc {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var list []*Doc
|
||||
for _, d := range s.docs {
|
||||
list = append(list, d)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].CreatedAt.After(list[j].CreatedAt)
|
||||
})
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].CreatedAt.After(list[j].CreatedAt) })
|
||||
if len(list) > n {
|
||||
list = list[:n]
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Remove 从文档存储中删除指定 ID 的文档
|
||||
func (s *Store) Remove(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.docs[id]; ok {
|
||||
s.removeDoc(id)
|
||||
s.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// ——— internal ———
|
||||
|
||||
// addSummary 添加一条摘要到训练集,超限时截断并触发重索引。
|
||||
// 调用方必须已持有 s.mu 写锁。
|
||||
func (s *Store) addSummary(summary string) {
|
||||
s.summaries = append(s.summaries, summary)
|
||||
if len(s.summaries) > maxSummaries {
|
||||
n := maxSummaries / 2
|
||||
copy(s.summaries, s.summaries[len(s.summaries)-n:])
|
||||
s.summaries = s.summaries[:n]
|
||||
s.veczer.Train(s.summaries)
|
||||
s.vec = vector.NewStore()
|
||||
for _, doc := range s.docs {
|
||||
vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content)
|
||||
s.vec.Insert(doc.ID, doc.Summary, vec, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeDoc 从内存索引和磁盘删除文档。
|
||||
// 调用方必须已持有 s.mu 写锁。
|
||||
func (s *Store) removeDoc(id string) {
|
||||
delete(s.docs, id)
|
||||
s.vec.Remove(id)
|
||||
path := filepath.Join(s.dir, id+".json")
|
||||
os.Remove(path)
|
||||
if s.tfidfIdx != nil {
|
||||
s.tfidfIdx.Remove(id)
|
||||
}
|
||||
os.Remove(filepath.Join(s.dir, id+".json"))
|
||||
}
|
||||
|
||||
func (s *Store) loadAll() error {
|
||||
@ -382,63 +525,51 @@ func (s *Store) loadAll() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".json") || !strings.HasPrefix(e.Name(), "doc_") {
|
||||
// 只要求 .json:Insert 接受任意 ID 并落盘为 <id>.json,若这里再按
|
||||
// doc_ 前缀过滤,传自定义 ID 的文档重启后会静默消失。
|
||||
// 空 ID 仍会被下面的校验跳过。
|
||||
if !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(s.dir, e.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var doc Doc
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
if json.Unmarshal(data, &doc) != nil || doc.ID == "" {
|
||||
continue
|
||||
}
|
||||
s.docs[doc.ID] = &doc
|
||||
s.summaries = append(s.summaries, doc.Summary)
|
||||
// 缓存文本,延迟训练(确保TFIDF在首次需要时才加载)
|
||||
s.trainTexts = append(s.trainTexts, doc.ID, doc.Summary+" "+doc.Content)
|
||||
}
|
||||
|
||||
// 训练向量化器
|
||||
if len(s.summaries) > 0 {
|
||||
s.veczer.Train(s.summaries)
|
||||
}
|
||||
|
||||
// 重建向量索引
|
||||
for _, doc := range s.docs {
|
||||
vec := doc.Vector
|
||||
if vec == nil {
|
||||
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
|
||||
}
|
||||
s.vec.Insert(doc.ID, doc.Summary, vec, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) flush() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.flushLocked()
|
||||
}
|
||||
|
||||
// flushLocked 是 flush 的核心,调用方必须已持有 s.mu。
|
||||
func (s *Store) flushLocked() {
|
||||
if !s.dirty {
|
||||
return
|
||||
}
|
||||
|
||||
for _, doc := range s.docs {
|
||||
path := filepath.Join(s.dir, doc.ID+".json")
|
||||
data, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
os.WriteFile(path, data, 0644)
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(filepath.Join(s.dir, doc.ID+".json"), data, 0644)
|
||||
}
|
||||
s.dirty = false
|
||||
}
|
||||
|
||||
// ——— 内部工具函数(从上下文提炼文档所需)———
|
||||
|
||||
type ToolResultItem struct {
|
||||
Name string
|
||||
Output string
|
||||
Name string `json:"name"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
type ContextEntry struct {
|
||||
@ -447,6 +578,23 @@ type ContextEntry struct {
|
||||
Content string
|
||||
Response string
|
||||
ToolResults []ToolResultItem
|
||||
Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档
|
||||
}
|
||||
|
||||
func blocksFromEntries(entries []ContextEntry) []memory.MemoryBlock {
|
||||
seen := make(map[string]bool)
|
||||
var out []memory.MemoryBlock
|
||||
for _, e := range entries {
|
||||
for i := range e.Blocks {
|
||||
b := e.Blocks[i]
|
||||
if b.ID == "" || seen[b.ID] {
|
||||
continue
|
||||
}
|
||||
seen[b.ID] = true
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func summarizeEntries(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) string {
|
||||
@ -478,14 +626,12 @@ func summarizeEntries(entries []ContextEntry, cleanText func(string) string, too
|
||||
topics = append(topics, toolWords...)
|
||||
}
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries))
|
||||
var srcList []string
|
||||
for s := range sources {
|
||||
srcList = append(srcList, s)
|
||||
}
|
||||
summary += " (" + strings.Join(srcList, ", ") + ")"
|
||||
|
||||
if len(topics) > 0 {
|
||||
seen := make(map[string]bool)
|
||||
var uniq []string
|
||||
@ -500,7 +646,6 @@ func summarizeEntries(entries []ContextEntry, cleanText func(string) string, too
|
||||
}
|
||||
summary += " 涉及: " + strings.Join(uniq, ", ")
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
@ -573,25 +718,20 @@ func extractEntities(entries []ContextEntry, cleanText func(string) string, tool
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(entities) > 20 {
|
||||
entities = entities[:20]
|
||||
}
|
||||
return entities
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) > max {
|
||||
return string(runes[:max]) + "..."
|
||||
if len([]rune(s)) <= max {
|
||||
return s
|
||||
}
|
||||
return s
|
||||
return string([]rune(s)[:max]) + "..."
|
||||
}
|
||||
|
||||
func simpleHash(s string) string {
|
||||
// 简单的基于内容的哈希,用于去重
|
||||
h := 0
|
||||
for _, r := range s {
|
||||
h = h*31 + int(r)
|
||||
h := fmt.Sprintf("%x", len(s))
|
||||
for _, c := range s {
|
||||
h += fmt.Sprintf("%x", c)
|
||||
}
|
||||
return fmt.Sprintf("h%08x", h)
|
||||
return h
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ func TestInsertAndQuery(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -48,7 +48,7 @@ func TestQuery(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -73,7 +73,7 @@ func TestContextToDoc(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -104,7 +104,7 @@ func TestFindColdDocs(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -136,7 +136,7 @@ func TestRecentDocs(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -160,7 +160,7 @@ func TestReindex(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -211,7 +211,7 @@ func TestInsertEmptyDoc(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -232,13 +232,13 @@ func TestPersistence(t *testing.T) {
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
// 写
|
||||
s1 := NewStore(dir)
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
s1.Start()
|
||||
s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
|
||||
s1.Stop()
|
||||
|
||||
// 读
|
||||
s2 := NewStore(dir)
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
s2.Start()
|
||||
defer s2.Stop()
|
||||
|
||||
@ -269,7 +269,7 @@ func TestFlushNoDirty(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
|
||||
// 不插任何文档,flush 不应报错
|
||||
@ -283,7 +283,7 @@ func TestRemove(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -329,7 +329,7 @@ func TestRemoveNonexistent(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -437,7 +437,7 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
s := NewStore(dir)
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
@ -467,3 +467,188 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
|
||||
t.Error("summary should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ———— 回归:向量迁移的落盘与维度一致性 ————
|
||||
//
|
||||
// 以下四条来自 v1.2.0-beta.2 的压测(报告 /var/tmp/stress/REPORT.md):
|
||||
// 迁移结果不落盘(每次启动白算一遍)、块指纹对但维度错时污染文档向量、
|
||||
// 以及 Insert 与 loadAll 的 ID 约定不对称。
|
||||
|
||||
// fakeSpace 是可控的统一向量空间;calls 记录被真正要求算向量的次数,
|
||||
// 用来直接证明「已对齐的文档不再重算」——比读日志断言可靠。
|
||||
type fakeSpace struct {
|
||||
fp string
|
||||
dim int
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeSpace) VectorizeDense(string) ([]float64, error) {
|
||||
f.calls++
|
||||
v := make([]float64, f.dim)
|
||||
for i := range v {
|
||||
v[i] = float64(i + 1)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (f *fakeSpace) EmbedImageDense([]byte, string) ([]float64, error) { return f.VectorizeDense("") }
|
||||
func (f *fakeSpace) Fingerprint() string { return f.fp }
|
||||
func (f *fakeSpace) Dim() int { return f.dim }
|
||||
func (f *fakeSpace) Loaded() bool { return true }
|
||||
func (f *fakeSpace) Close() {}
|
||||
|
||||
// 迁移结果必须落盘:迁移后换一个 Store 实例(模拟重启)读到的应是新空间向量,
|
||||
// 且再跑一次迁移不应重算任何文档。
|
||||
func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sp := &fakeSpace{fp: "space-NEW-8", dim: 8}
|
||||
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟上个向量空间留下的状态:维度与指纹都对不上
|
||||
stale := make([]float64, 999)
|
||||
for i := range stale {
|
||||
stale[i] = 0.01
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "doc_persist", Summary: "迁移", Content: "落盘",
|
||||
DenseVec: stale, DenseFP: "space-OLD-999"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1.SetDenseSpace(sp)
|
||||
s1.BuildDenseIndex(sp)
|
||||
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != sp.fp {
|
||||
t.Fatalf("迁移未在内存生效: %+v", got)
|
||||
}
|
||||
// 不调用 Stop 就另开一个实例:体现「迁移当场落盘」,不依赖关停
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
loaded := s2.Get("doc_persist")
|
||||
if loaded == nil {
|
||||
t.Fatal("重启后文档不见了")
|
||||
}
|
||||
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp {
|
||||
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
|
||||
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
|
||||
sp.fp, len(loaded.DenseVec), loaded.DenseFP)
|
||||
}
|
||||
// 已对齐 → 一次向量计算都不该发生
|
||||
sp2 := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
s2.SetDenseSpace(sp2)
|
||||
s2.BuildDenseIndex(sp2)
|
||||
if sp2.calls != 0 {
|
||||
t.Fatalf("已对齐的文档被重算了 %d 次(期望 0)", sp2.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 块向量维度与当前空间不符时不得参与融合:否则 512 维文本 + 2048 维块
|
||||
// 会被 FuseVectors 按最大维度拼成 2048 维、并带上当前指纹,导致该文档在
|
||||
// 检索侧被长度守卫永久跳过且每次启动重算。
|
||||
func TestDenseForIgnoresBlockWithMismatchedDim(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sp := &fakeSpace{fp: "space-NEW-8", dim: 8}
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
bad := make([]float64, 2048)
|
||||
for i := range bad {
|
||||
bad[i] = 0.02
|
||||
}
|
||||
good := make([]float64, 8)
|
||||
for i := range good {
|
||||
good[i] = 0.5
|
||||
}
|
||||
doc := &Doc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效",
|
||||
Blocks: []memory.MemoryBlock{
|
||||
{ID: "blk_bad", Vector: bad, Fingerprint: sp.fp}, // 指纹对、维度错 → 必须忽略
|
||||
{ID: "blk_good", Vector: good, Fingerprint: sp.fp}, // 指纹与维度都对 → 参与融合
|
||||
}}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetDenseSpace(sp)
|
||||
s.BuildDenseIndex(sp)
|
||||
|
||||
got := s.Get("doc_bad")
|
||||
if got == nil {
|
||||
t.Fatal("文档未加载")
|
||||
}
|
||||
if len(got.DenseVec) != 8 {
|
||||
t.Fatalf("坏块污染了文档向量:期望 %d 维,实际 %d 维(指纹 %q)",
|
||||
8, len(got.DenseVec), got.DenseFP)
|
||||
}
|
||||
// 同维度的正常块仍须参与融合:不应因为这次修复而整体失效
|
||||
textOnly := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
textVec, _ := textOnly.VectorizeDense(doc.Summary + " " + doc.Content)
|
||||
if equalFloats(got.DenseVec, textVec) {
|
||||
t.Fatal("同维度的媒体块没有参与融合(修复过度)")
|
||||
}
|
||||
}
|
||||
|
||||
func equalFloats(a, b []float64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Insert 接受任意 ID,loadAll 就必须把它读回来,否则自定义 ID 的文档
|
||||
// 重启后静默消失。
|
||||
func TestLoadAllLoadsCustomID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "my-notes", Summary: "自定义 ID", Content: "内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1.Stop()
|
||||
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
if s2.Get("my-notes") == nil {
|
||||
t.Fatal("自定义 ID 的文档重启后消失(Insert 与 loadAll 的 ID 约定不对称)")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 必须把内存态变更写盘(关停链上没有它时 flush 形同虚设)。
|
||||
func TestStopFlushesDirtyDocs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "doc_flush", Summary: "关停落盘", Content: "内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 直接改内存并置脏,模拟「只在内存里发生的变更」
|
||||
s1.mu.Lock()
|
||||
s1.docs["doc_flush"].Summary = "关停落盘(已改)"
|
||||
s1.dirty = true
|
||||
s1.mu.Unlock()
|
||||
s1.Stop()
|
||||
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
if got := s2.Get("doc_flush"); got == nil || got.Summary != "关停落盘(已改)" {
|
||||
t.Fatalf("Stop 未落盘: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package memory
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -44,6 +45,10 @@ type Triple struct {
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
ObjectType string `json:"object_type,omitempty"`
|
||||
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表
|
||||
// MediaDigests 是该三元组显式携带的媒体 digest(完整或前缀)。
|
||||
// 媒体不再靠正文 marker 反解:结构化字段直接给出归属,
|
||||
// 由调用方(core)把它变成 L3 一等块并与句子建立结构边。
|
||||
MediaDigests []string `json:"media_digests,omitempty"`
|
||||
}
|
||||
|
||||
type GraphDB struct {
|
||||
@ -107,6 +112,41 @@ func (g *GraphDB) initSchema() error {
|
||||
FOREIGN KEY (target_id) REFERENCES entities(id),
|
||||
UNIQUE(source_id, target_id, relation_type, session_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS memory_blocks (
|
||||
id TEXT PRIMARY KEY,
|
||||
modality TEXT NOT NULL,
|
||||
text_content TEXT DEFAULT '',
|
||||
payload_digest TEXT DEFAULT '',
|
||||
mime TEXT DEFAULT '',
|
||||
size INTEGER DEFAULT 0,
|
||||
width INTEGER DEFAULT 0,
|
||||
height INTEGER DEFAULT 0,
|
||||
vector TEXT DEFAULT '',
|
||||
fingerprint TEXT DEFAULT '',
|
||||
source TEXT DEFAULT '',
|
||||
tool TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS memory_block_edges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
target_kind TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
edge_type TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(source_kind, source_id, target_kind, target_id, edge_type)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
summary TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_modality ON memory_blocks(modality)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_target ON memory_block_edges(target_kind, target_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_entity_name ON entities(name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_relation_source ON relations(source_id)`,
|
||||
@ -205,7 +245,7 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
|
||||
// 不划算。这里让 Commit 内部转调,两者共享同一份落库逻辑。
|
||||
//
|
||||
// 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体
|
||||
// 引用挂到 graph_sentence owner 上——句子是媒体描述在图库里的落点,
|
||||
// 变成 L3 一等块,并以 sentence --contains--> block 边与句子相连;
|
||||
// 关系行本身不持有媒体。
|
||||
func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) {
|
||||
return g.commit(triples, sessionID, turnID, true)
|
||||
@ -714,9 +754,58 @@ func (g *GraphDB) GraphData() (map[string]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
brows, err := g.db.Query(`SELECT id, modality, text_content, payload_digest, mime,
|
||||
size, width, height, vector, fingerprint, source, tool, created_at, updated_at
|
||||
FROM memory_blocks ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer brows.Close()
|
||||
var blocks []MemoryBlock
|
||||
for brows.Next() {
|
||||
var block MemoryBlock
|
||||
var vectorJSON string
|
||||
if err := brows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
|
||||
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
|
||||
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
|
||||
&block.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vectorJSON != "" && vectorJSON != "null" {
|
||||
if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil {
|
||||
return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err)
|
||||
}
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
if err := brows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
berows, err := g.db.Query(`SELECT id, source_kind, source_id, target_kind, target_id,
|
||||
edge_type, created_at FROM memory_block_edges ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer berows.Close()
|
||||
var blockEdges []MemoryBlockEdge
|
||||
for berows.Next() {
|
||||
var edge MemoryBlockEdge
|
||||
if err := berows.Scan(&edge.ID, &edge.SourceKind, &edge.SourceID,
|
||||
&edge.TargetKind, &edge.TargetID, &edge.Type, &edge.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blockEdges = append(blockEdges, edge)
|
||||
}
|
||||
if err := berows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"nodes": entities,
|
||||
"edges": relations,
|
||||
"nodes": entities,
|
||||
"edges": relations,
|
||||
"memory_blocks": blocks,
|
||||
"memory_block_edges": blockEdges,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -899,17 +988,42 @@ func (g *GraphDB) ClearSentenceID(relationID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanupOrphanedSentences 删除没有任何关系引用的句子,返回删除数
|
||||
// CleanupOrphanedSentences 删除既无关系引用、也无媒体块边的句子,返回删除数。
|
||||
//
|
||||
// 两个条件都必须看:旧媒体实体被迁移成原生块后,那些句子可能只靠
|
||||
// sentence --contains--> block 存活,若只看 relations 引用就会被误删,
|
||||
// 连带把块边变成悬空引用。
|
||||
func (g *GraphDB) CleanupOrphanedSentences() (int, error) {
|
||||
result, err := g.db.Exec(
|
||||
`DELETE FROM sentences WHERE id NOT IN (
|
||||
SELECT DISTINCT sentence_id FROM relations WHERE sentence_id != 0
|
||||
)`,
|
||||
)
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
defer tx.Rollback()
|
||||
|
||||
// 先清掉指向将被删除句子的块边,避免留下悬空端点。
|
||||
if _, err := tx.Exec(`DELETE FROM memory_block_edges
|
||||
WHERE source_kind = 'sentence' AND source_id NOT IN (
|
||||
SELECT CAST(id AS TEXT) FROM sentences
|
||||
WHERE id IN (SELECT DISTINCT sentence_id FROM relations WHERE sentence_id != 0)
|
||||
OR id IN (SELECT CAST(source_id AS INTEGER) FROM memory_block_edges WHERE source_kind = 'sentence')
|
||||
)`); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM sentences WHERE id NOT IN (
|
||||
SELECT DISTINCT sentence_id FROM relations WHERE sentence_id != 0
|
||||
) AND id NOT IN (
|
||||
SELECT CAST(source_id AS INTEGER) FROM memory_block_edges WHERE source_kind = 'sentence'
|
||||
)`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
|
||||
@ -2,8 +2,8 @@ package memory
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -434,6 +434,84 @@ func TestMergeEntitiesNonexistent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryBlocksAreFirstClassGraphNodes(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
image := MemoryBlock{
|
||||
ID: "block_image_1", Modality: BlockImage,
|
||||
PayloadDigest: "0123456789abcdef", MIME: "image/png", Size: 1234,
|
||||
Width: 768, Height: 512, Vector: []float64{0.1, 0.2, 0.3},
|
||||
Fingerprint: "qwen:test", Source: "qq", Tool: "upload",
|
||||
}
|
||||
text := MemoryBlock{ID: "block_text_1", Modality: BlockText, Text: "用户上传了一张架构图"}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{image, text}); err != nil {
|
||||
t.Fatalf("PutMemoryBlocks: %v", err)
|
||||
}
|
||||
if err := g.AddMemoryBlockEdge("block", text.ID, "block", image.ID, "contains"); err != nil {
|
||||
t.Fatalf("AddMemoryBlockEdge: %v", err)
|
||||
}
|
||||
|
||||
blocks, err := g.MemoryBlocks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("memory blocks=%d, want 2", len(blocks))
|
||||
}
|
||||
var gotImage *MemoryBlock
|
||||
for i := range blocks {
|
||||
if blocks[i].ID == image.ID {
|
||||
gotImage = &blocks[i]
|
||||
}
|
||||
}
|
||||
if gotImage == nil || gotImage.Modality != BlockImage || gotImage.PayloadDigest != image.PayloadDigest || gotImage.Fingerprint != image.Fingerprint || len(gotImage.Vector) != 3 {
|
||||
t.Fatalf("image block not round-tripped: %+v", gotImage)
|
||||
}
|
||||
|
||||
edges, err := g.MemoryBlockEdges()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(edges) != 1 || edges[0].Type != "contains" || edges[0].SourceID != text.ID || edges[0].TargetID != image.ID {
|
||||
t.Fatalf("memory block edges=%+v", edges)
|
||||
}
|
||||
|
||||
graph, err := g.GraphData()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
graphBlocks, ok := graph["memory_blocks"].([]MemoryBlock)
|
||||
if !ok || len(graphBlocks) != 2 {
|
||||
t.Fatalf("GraphData memory_blocks=%T %+v", graph["memory_blocks"], graph["memory_blocks"])
|
||||
}
|
||||
graphEdges, ok := graph["memory_block_edges"].([]MemoryBlockEdge)
|
||||
if !ok || len(graphEdges) != 1 {
|
||||
t.Fatalf("GraphData memory_block_edges=%T %+v", graph["memory_block_edges"], graph["memory_block_edges"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryBlockEdgeRejectsMissingEndpoint(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{{ID: "known", Modality: BlockText, Text: "known"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := g.AddMemoryBlockEdge("block", "known", "block", "missing", "derived_from"); err == nil {
|
||||
t.Fatal("edge to missing node must fail")
|
||||
}
|
||||
edges, err := g.MemoryBlockEdges()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(edges) != 0 {
|
||||
t.Fatalf("failed transaction left edges: %+v", edges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholders(t *testing.T) {
|
||||
if placeholders(0) != "NULL" {
|
||||
t.Errorf("expected NULL for n=0, got %s", placeholders(0))
|
||||
|
||||
@ -10,19 +10,19 @@ import (
|
||||
)
|
||||
|
||||
type Indexer struct {
|
||||
db *GraphDB
|
||||
vec *vector.Store
|
||||
veczer *vector.TFIDFVectorizer
|
||||
mu sync.RWMutex
|
||||
trained bool
|
||||
recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过
|
||||
db *GraphDB
|
||||
vec *vector.Store
|
||||
veczer *vector.TFIDFVectorizer
|
||||
mu sync.RWMutex
|
||||
trained bool
|
||||
recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过
|
||||
}
|
||||
|
||||
func NewIndexer(db *GraphDB) *Indexer {
|
||||
return &Indexer{
|
||||
db: db,
|
||||
vec: vector.NewStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(TokenizeWords),
|
||||
db: db,
|
||||
vec: vector.NewStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(TokenizeWords),
|
||||
recalled: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
123
internal/memory/jieba_embed.go
Normal file
123
internal/memory/jieba_embed.go
Normal file
@ -0,0 +1,123 @@
|
||||
// Package memory 的 jieba 词库内嵌。
|
||||
//
|
||||
// 为什么要把词库嵌进二进制,而不是像以前那样去猜 Go 模块缓存路径:
|
||||
//
|
||||
// 原实现是 `jiebaDictDir()` 依次试 GOMODCACHE / GOPATH / ~/go/pkg/mod,去找
|
||||
// `github.com/yanyiwu/gojieba@v1.4.7/deps/cppjieba/dict`。部署机上通常**没有**
|
||||
// Go 模块缓存,于是返回 "",`GetJieba()` 返回 nil,四个分词/关键词函数
|
||||
// **一律静默返回空列表**(只在首次打一行「jieba disabled」)。
|
||||
//
|
||||
// 后果不是「少了个优化」而是**能力整体消失**:图记忆的关键词提取、文档
|
||||
// TF-IDF 分词、NLP 依存解析(进而 doc→graph 三元组抽取)全部退化为空。
|
||||
// 而本机之所以看起来正常,只是因为开发机与生产机重合、恰好有那份模块缓存。
|
||||
//
|
||||
// 内嵌后词库成为产物的一部分:与二进制同版本、随二进制分发、不依赖宿主环境。
|
||||
// 代价是包体大 ~11.6MB(jieba.dict.utf8 5.1M + idf.utf8 6.0M + hmm_model 0.5M + …),
|
||||
// 这是可接受的——它换来的是「装到哪都能用」。
|
||||
//
|
||||
// 关于 POS:gojieba 的 Tag() 不读 `pos_dict/` 目录,而是从主词典每行的
|
||||
// 词性列取 tag(cppjieba 的 PosTagger::LookupTag 走 dict->Find(...)->tag),
|
||||
// 取不到时用 SpecialRule 按字符类型兜底。所以这 5 个文件已足够同时支撑
|
||||
// Cut 与 Tag,无需再嵌 pos_dict/。
|
||||
package memory
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
//go:embed jiebadict/*
|
||||
var jiebaDictFS embed.FS
|
||||
|
||||
// jiebaDictFiles 是 gojieba.NewJieba 需要的 5 个文件,顺序与它的参数一致:
|
||||
// dict, hmm, user, idf, stop_words。
|
||||
var jiebaDictFiles = []string{
|
||||
"jieba.dict.utf8",
|
||||
"hmm_model.utf8",
|
||||
"user.dict.utf8",
|
||||
"idf.utf8",
|
||||
"stop_words.utf8",
|
||||
}
|
||||
|
||||
// materializeJiebaDict 把内嵌词库落盘,返回目录路径。
|
||||
//
|
||||
// gojieba 的 C++ API 只接受**文件路径**(NewJieba 会对每个路径 os.Stat,
|
||||
// 缺失就 panic),所以必须先落盘再传路径。
|
||||
//
|
||||
// 落盘位置与幂等性:
|
||||
// - 用内容哈希命名目录:词库升级后不会复用旧文件(否则会出现「新旧词库混用」
|
||||
// 这种最难查的一类问题——分词结果与版本对不上)。
|
||||
// - 已存在且大小一致就跳过写入:正常启动只做几次 stat。
|
||||
func materializeJiebaDict() (string, error) {
|
||||
sum, err := jiebaDictDigest()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, err := os.UserCacheDir()
|
||||
if err != nil || base == "" {
|
||||
base = os.TempDir()
|
||||
}
|
||||
dir := filepath.Join(base, "homeagent", "jieba-"+sum)
|
||||
|
||||
if jiebaDictComplete(dir) {
|
||||
return dir, nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("创建词库目录: %w", err)
|
||||
}
|
||||
for _, name := range jiebaDictFiles {
|
||||
data, err := jiebaDictFS.ReadFile("jiebadict/" + name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取内嵌词库 %s: %w", name, err)
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
// 先写临时文件再 rename:避免并发启动时读到写了一半的词库。
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("写出词库 %s: %w", name, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return "", fmt.Errorf("落位词库 %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// jiebaDictDigest 对全部内嵌词库内容求哈希,作为落盘目录名的一部分。
|
||||
func jiebaDictDigest() (string, error) {
|
||||
h := sha256.New()
|
||||
// 按固定顺序喂入:embed.FS 的遍历顺序不保证稳定,顺序变了哈希就变,
|
||||
// 会导致每次启动都重建一份词库。
|
||||
names := append([]string(nil), jiebaDictFiles...)
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
data, err := jiebaDictFS.ReadFile("jiebadict/" + name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取内嵌词库 %s: %w", name, err)
|
||||
}
|
||||
fmt.Fprintf(h, "%s:%d:", name, len(data))
|
||||
h.Write(data)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:16], nil
|
||||
}
|
||||
|
||||
// jiebaDictComplete 判断目录下 5 个词库是否齐全且大小与内嵌版本一致。
|
||||
func jiebaDictComplete(dir string) bool {
|
||||
for _, name := range jiebaDictFiles {
|
||||
want, err := fs.Stat(jiebaDictFS, "jiebadict/"+name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := os.Stat(filepath.Join(dir, name))
|
||||
if err != nil || got.Size() != want.Size() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
98
internal/memory/jieba_embed_test.go
Normal file
98
internal/memory/jieba_embed_test.go
Normal file
@ -0,0 +1,98 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 内嵌词库必须自足:把 GOMODCACHE/GOPATH/HOME 全部指向不存在的路径,
|
||||
// 分词与关键词提取仍然要工作。
|
||||
//
|
||||
// 这正是修复前的故障场景:原实现只去猜 Go 模块缓存,部署机上没有那份缓存时
|
||||
// GetJieba() 返回 nil,四个函数静默返回空列表——关键词提取、文档 TF-IDF 分词、
|
||||
// NLP 依存解析(进而 doc→graph 三元组抽取)一起失效,且只有一行日志。
|
||||
func TestEmbeddedJiebaDictIsSelfContained(t *testing.T) {
|
||||
t.Setenv("GOMODCACHE", filepath.Join(t.TempDir(), "nonexistent"))
|
||||
t.Setenv("GOPATH", filepath.Join(t.TempDir(), "nonexistent"))
|
||||
t.Setenv("HOME", filepath.Join(t.TempDir(), "nonexistent"))
|
||||
// 清掉可能已被其它测试初始化过的单例。
|
||||
jiebaOnce = sync.Once{}
|
||||
jiebaInst = nil
|
||||
t.Cleanup(func() {
|
||||
jiebaOnce = sync.Once{}
|
||||
jiebaInst = nil
|
||||
})
|
||||
|
||||
if x := GetJieba(); x == nil {
|
||||
t.Fatal("模块缓存不可见时 jieba 必须仍能初始化(词库应来自内嵌副本)")
|
||||
}
|
||||
|
||||
words := TokenizeWords("今天天气很好,我们去公园散步")
|
||||
if len(words) == 0 {
|
||||
t.Fatal("分词结果为空:内嵌词库没有真正生效")
|
||||
}
|
||||
t.Logf("分词结果: %v", words)
|
||||
|
||||
// 内容词(名词/动词/形容词)——依赖词典里的词性列,顺便验证 Tag 路径可用。
|
||||
content := TokenizeContentWords("北京是中国的首都,这里有很多历史建筑")
|
||||
if len(content) == 0 {
|
||||
t.Fatal("内容词为空:Tag(词性标注)路径失效")
|
||||
}
|
||||
t.Logf("内容词: %v", content)
|
||||
|
||||
if kw := ExtractKeywords("机器学习模型训练需要大量数据和算力"); len(kw) == 0 {
|
||||
t.Fatal("关键词为空")
|
||||
}
|
||||
}
|
||||
|
||||
// 落盘目录必须幂等:第二次调用不应重写文件(正常启动只做几次 stat)。
|
||||
func TestMaterializeJiebaDictIsIdempotent(t *testing.T) {
|
||||
dir, err := materializeJiebaDict()
|
||||
if err != nil {
|
||||
t.Fatalf("materializeJiebaDict: %v", err)
|
||||
}
|
||||
for _, name := range jiebaDictFiles {
|
||||
p := filepath.Join(dir, name)
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatalf("缺少词库文件 %s: %v", name, err)
|
||||
}
|
||||
if fi.Size() == 0 {
|
||||
t.Fatalf("词库文件 %s 为空", name)
|
||||
}
|
||||
}
|
||||
|
||||
// 记下 mtime,再调一次,必须完全没动过。
|
||||
before, _ := os.Stat(filepath.Join(dir, "jieba.dict.utf8"))
|
||||
dir2, err := materializeJiebaDict()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dir2 != dir {
|
||||
t.Fatalf("目录名不稳定:%s vs %s(会导致每次启动重建词库)", dir, dir2)
|
||||
}
|
||||
after, _ := os.Stat(filepath.Join(dir, "jieba.dict.utf8"))
|
||||
if !before.ModTime().Equal(after.ModTime()) {
|
||||
t.Fatal("已存在完整词库时不应重写文件")
|
||||
}
|
||||
}
|
||||
|
||||
// 词库内容哈希必须稳定:否则目录名每次都变,等于每次启动都重建。
|
||||
func TestJiebaDictDigestStable(t *testing.T) {
|
||||
a, err := jiebaDictDigest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := jiebaDictDigest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("哈希不稳定: %s vs %s", a, b)
|
||||
}
|
||||
if len(a) != 16 {
|
||||
t.Fatalf("哈希长度异常: %q", a)
|
||||
}
|
||||
}
|
||||
34
internal/memory/jiebadict/hmm_model.utf8
Normal file
34
internal/memory/jiebadict/hmm_model.utf8
Normal file
File diff suppressed because one or more lines are too long
258826
internal/memory/jiebadict/idf.utf8
Normal file
258826
internal/memory/jiebadict/idf.utf8
Normal file
File diff suppressed because it is too large
Load Diff
348982
internal/memory/jiebadict/jieba.dict.utf8
Normal file
348982
internal/memory/jiebadict/jieba.dict.utf8
Normal file
File diff suppressed because it is too large
Load Diff
1534
internal/memory/jiebadict/stop_words.utf8
Normal file
1534
internal/memory/jiebadict/stop_words.utf8
Normal file
File diff suppressed because it is too large
Load Diff
4
internal/memory/jiebadict/user.dict.utf8
Normal file
4
internal/memory/jiebadict/user.dict.utf8
Normal file
@ -0,0 +1,4 @@
|
||||
云计算
|
||||
韩玉鉴赏
|
||||
蓝翔 nz
|
||||
区块链 10 nz
|
||||
@ -11,7 +11,7 @@
|
||||
// - 路径会失效。/tmp 下的探针图、下载缓存、其他进程的临时产物,记忆里留个
|
||||
// 路径等于留个悬空指针。
|
||||
// - 同一张图往往被反复注入(用户连问几轮同一张截图、see_video 相邻帧高度
|
||||
// 相似)。按 sha256 寻址天然去重,引用计数记住被引了几次。
|
||||
// 相似)。按 sha256 寻址天然去重,同一份字节只存一遍。
|
||||
// - 内容即身份,跟 L3 图库 `sentences.text UNIQUE` 的思路一致:文本节点用
|
||||
// 文本本身做身份,媒体节点用内容摘要做身份。
|
||||
package media
|
||||
@ -23,8 +23,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@ -32,20 +34,6 @@ import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// OwnerKind 是 media_refs.owner_kind 的取值,对应引用媒体的记忆层。
|
||||
//
|
||||
// 定义为常量而不是让调用方写字符串:owner_kind 进了主键,
|
||||
// 拼错一个字符就是一条永远对不上的孤立引用(AddRef 不会报错,
|
||||
// DropOwner 也永远匹配不到)。
|
||||
const (
|
||||
// OwnerContext 是 L0 对话上下文事件(ContextEvent.ID)。
|
||||
OwnerContext = "context"
|
||||
// OwnerDocument 是 L2 文档记忆(Doc.ID)。
|
||||
OwnerDocument = "document"
|
||||
// OwnerGraphSentence 是 L3 图库句子节点(sentences.id)。
|
||||
OwnerGraphSentence = "graph_sentence"
|
||||
)
|
||||
|
||||
// digestHexLen 是 sha256 的十六进制串长度。
|
||||
const digestHexLen = sha256.Size * 2
|
||||
|
||||
@ -81,16 +69,15 @@ type Item struct {
|
||||
OriginPath string `json:"origin_path,omitempty"`
|
||||
// Tool 是注入这条媒体的工具名(如 multimodal_see_picture)。
|
||||
Tool string `json:"tool,omitempty"`
|
||||
// Description 是视觉/音频模型生成的文字描述,供 L2/L3 检索。
|
||||
// 空表示未描述(未开启描述、模型不可用或描述失败)。
|
||||
Description string `json:"description,omitempty"`
|
||||
// DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。
|
||||
DescribedBy string `json:"described_by,omitempty"`
|
||||
// RefCount 是引用计数。GC 只清理归零的项。
|
||||
RefCount int `json:"ref_count"`
|
||||
// FirstSeen/LastSeen 是首末次入库时间。
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
// --- 多模态嵌入(v1.2.0)---
|
||||
// Vec 是视觉嵌入向量的序列化(JSON []float64),nil 表示未嵌入。
|
||||
Vec []float64 `json:"vec,omitempty"`
|
||||
// VecModel 是产生 Vec 的模型标识(如 "clip-vit-b32"),
|
||||
// 用于模型切换后判断是否需要重算。
|
||||
VecModel string `json:"vec_model,omitempty"`
|
||||
}
|
||||
|
||||
// Store 管理媒体的元数据(SQLite)与内容(磁盘 CAS 目录)。
|
||||
@ -102,15 +89,11 @@ type Store struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
blobDir string
|
||||
|
||||
// maxBytes 是内容目录的容量上限,0 表示不限。
|
||||
// 超限时 GC 按 LastSeen 从旧到新淘汰 RefCount=0 的项。
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
// New 打开(或初始化)媒体存储。
|
||||
// dir 下会建 media.db 与 blobs/ 两个条目。
|
||||
func New(dir string, maxBytes int64) (*Store, error) {
|
||||
func New(dir string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0755); err != nil {
|
||||
return nil, fmt.Errorf("media: create blob dir: %w", err)
|
||||
}
|
||||
@ -119,7 +102,7 @@ func New(dir string, maxBytes int64) (*Store, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("media: open db: %w", err)
|
||||
}
|
||||
s := &Store{db: db, blobDir: filepath.Join(dir, "blobs"), maxBytes: maxBytes}
|
||||
s := &Store{db: db, blobDir: filepath.Join(dir, "blobs")}
|
||||
if err := s.initSchema(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
@ -129,7 +112,7 @@ func New(dir string, maxBytes int64) (*Store, error) {
|
||||
|
||||
func (s *Store) initSchema() error {
|
||||
stmts := []string{
|
||||
// digest 作主键:内容即身份,重复 Put 同一内容只递增 ref_count。
|
||||
// digest 作主键:内容即身份,重复 Put 同一内容不重复落盘。
|
||||
`CREATE TABLE IF NOT EXISTS media (
|
||||
digest TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
@ -139,33 +122,25 @@ func (s *Store) initSchema() error {
|
||||
height INTEGER DEFAULT 0,
|
||||
origin_path TEXT,
|
||||
tool TEXT,
|
||||
description TEXT,
|
||||
described_by TEXT,
|
||||
ref_count INTEGER DEFAULT 0,
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_kind ON media(kind)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_refcount ON media(ref_count)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_last_seen ON media(last_seen)`,
|
||||
// 反向索引:哪条记忆引用了哪个媒体。
|
||||
// owner_kind 取 context / document / graph_sentence,owner_id 是各层自己的标识。
|
||||
// 主键含三列,同一 owner 重复挂同一媒体是幂等的。
|
||||
`CREATE TABLE IF NOT EXISTS media_refs (
|
||||
digest TEXT NOT NULL,
|
||||
owner_kind TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (digest, owner_kind, owner_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_refs_owner ON media_refs(owner_kind, owner_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_refs_digest ON media_refs(digest)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return fmt.Errorf("media: schema %q: %w", truncate(q, 60), err)
|
||||
}
|
||||
}
|
||||
// v1.2.0 迁移:给 media 表加 vec(视觉嵌入向量 JSON)和 vec_model(模型标识)。
|
||||
migrations := []string{
|
||||
`ALTER TABLE media ADD COLUMN vec TEXT`,
|
||||
`ALTER TABLE media ADD COLUMN vec_model TEXT`,
|
||||
}
|
||||
for _, q := range migrations {
|
||||
_, _ = s.db.Exec(q) // 列已存在时返回 "duplicate column name",可忽略
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -214,20 +189,15 @@ func (s *Store) Put(data []byte, meta Item) (string, error) {
|
||||
}
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO media (digest, kind, mime, size, width, height,
|
||||
origin_path, tool, description, described_by,
|
||||
ref_count, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
origin_path, tool, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(digest) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
-- 只在原值为空时补写:先到的描述可能来自更强的模型,
|
||||
-- 后到的空值不该把它冲掉。
|
||||
description = CASE WHEN COALESCE(media.description,'') = '' THEN excluded.description ELSE media.description END,
|
||||
described_by = CASE WHEN COALESCE(media.described_by,'') = '' THEN excluded.described_by ELSE media.described_by END,
|
||||
width = CASE WHEN media.width = 0 THEN excluded.width ELSE media.width END,
|
||||
height = CASE WHEN media.height = 0 THEN excluded.height ELSE media.height END,
|
||||
tool = CASE WHEN COALESCE(media.tool,'') = '' THEN excluded.tool ELSE media.tool END
|
||||
`, digest, string(meta.Kind), meta.MIME, int64(len(data)), meta.Width, meta.Height,
|
||||
meta.OriginPath, meta.Tool, meta.Description, meta.DescribedBy, now, now)
|
||||
meta.OriginPath, meta.Tool, now, now)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("media: upsert meta: %w", err)
|
||||
}
|
||||
@ -260,332 +230,42 @@ func (s *Store) Stat(digest string) (*Item, error) {
|
||||
defer s.mu.RUnlock()
|
||||
return s.scanOne(s.db.QueryRow(`
|
||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, ref_count, first_seen, last_seen
|
||||
first_seen, last_seen,
|
||||
vec, vec_model
|
||||
FROM media WHERE digest = ?`, digest))
|
||||
}
|
||||
|
||||
// Describe 写入(或覆盖)文字描述。
|
||||
// Stat 返回元数据,不读内容。
|
||||
//
|
||||
// 与 Put 的"只在空时补写"不同:Describe 是显式操作,调用方明确想要这份
|
||||
// 描述生效(例如换了更强的视觉模型重新描述)。
|
||||
func (s *Store) Describe(digest, description, describedBy string) error {
|
||||
// 这不是 GC,也不看引用计数:调用方是记忆系统本身——当它把一个记忆块
|
||||
// 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。
|
||||
// 文本块就是这么管理的:删除块即删除内容。
|
||||
func (s *Store) Delete(digest string) error {
|
||||
if digest == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
res, err := s.db.Exec(`UPDATE media SET description = ?, described_by = ? WHERE digest = ?`,
|
||||
description, describedBy, digest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: describe: %w", err)
|
||||
if err := os.Remove(s.blobPath(digest)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("media: remove blob %s: %w", shortDigest(digest), err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("media: describe: unknown digest %s", shortDigest(digest))
|
||||
if _, err := s.db.Exec(`DELETE FROM media WHERE digest = ?`, digest); err != nil {
|
||||
return fmt.Errorf("media: delete meta %s: %w", shortDigest(digest), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRef 登记一条引用并递增计数。幂等:同一 (digest, owner) 重复调用不重复计数。
|
||||
func (s *Store) AddRef(digest, ownerKind, ownerID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(`INSERT OR IGNORE INTO media_refs (digest, owner_kind, owner_id) VALUES (?, ?, ?)`,
|
||||
digest, ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: add ref: %w", err)
|
||||
}
|
||||
// 只有真的插进去才递增:否则重复调用会让计数虚高,GC 永远不敢清。
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
if _, err := tx.Exec(`UPDATE media SET ref_count = ref_count + 1 WHERE digest = ?`, digest); err != nil {
|
||||
return fmt.Errorf("media: bump refcount: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DropRef 注销一条引用并递减计数。内容不立即删除,留给 GC。
|
||||
func (s *Store) DropRef(digest, ownerKind, ownerID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM media_refs WHERE digest = ? AND owner_kind = ? AND owner_id = ?`,
|
||||
digest, ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: drop ref: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
// MAX(0, ...) 兜底:历史数据或并发意外让计数与 refs 表不一致时,
|
||||
// 不让它掉成负数(负数会让容量 GC 的排序失去意义)。
|
||||
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, digest); err != nil {
|
||||
return fmt.Errorf("media: lower refcount: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DropOwner 注销某个 owner 的全部引用(该条记忆被删/被归档替换时用)。
|
||||
func (s *Store) DropOwner(ownerKind, ownerID string) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ?`,
|
||||
ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var digests []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err == nil {
|
||||
digests = append(digests, d)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM media_refs WHERE owner_kind = ? AND owner_id = ?`, ownerKind, ownerID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, d := range digests {
|
||||
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, d); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(digests), nil
|
||||
}
|
||||
|
||||
// Refs 返回某个 owner 引用的全部 digest。
|
||||
func (s *Store) Refs(ownerKind, ownerID string) ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ? ORDER BY created_at`,
|
||||
ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err == nil {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Search 按描述文本做 LIKE 匹配,返回最近的若干条。
|
||||
//
|
||||
// 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引
|
||||
// (描述文字随记忆条目一起进 Doc.Content,复用那套 TF-IDF/embedding),
|
||||
// 本方法只是"按关键词直接翻媒体库"的补充入口。
|
||||
func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
q := `SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, ref_count, first_seen, last_seen
|
||||
FROM media WHERE COALESCE(description,'') != ''`
|
||||
args := []interface{}{}
|
||||
if strings.TrimSpace(query) != "" {
|
||||
q += ` AND description LIKE ?`
|
||||
args = append(args, "%"+query+"%")
|
||||
}
|
||||
if kind != "" {
|
||||
q += ` AND kind = ?`
|
||||
args = append(args, string(kind))
|
||||
}
|
||||
q += ` ORDER BY last_seen DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Item
|
||||
for rows.Next() {
|
||||
it, err := s.scanRows(rows)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Pending 返回尚无描述的媒体,供后台描述任务消费。
|
||||
// Pending 返回尚无描述的媒体,供后台描述任务消费。
|
||||
//
|
||||
// 不只看 description 为空,还要求 described_by 也为空。
|
||||
// 因为“已尝试但无法描述”的项(如 kind=other 的二进制、blob 已丢失)
|
||||
// 会被标记为 described_by=unsupported/content-missing 而 description 仍为空——
|
||||
// 若只看 description,这些项每轮都会被取出来重试,永远卡在队列头部,
|
||||
// 真正需要描述的新项永远轮不到(LIMIT 只取前 N 条)。
|
||||
func (s *Store) Pending(limit int) ([]*Item, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(`
|
||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, ref_count, first_seen, last_seen
|
||||
FROM media
|
||||
WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = ''
|
||||
ORDER BY last_seen DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Item
|
||||
for rows.Next() {
|
||||
it, err := s.scanRows(rows)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GC 清理无人引用的内容。
|
||||
//
|
||||
// 两段策略:
|
||||
// 1. ref_count=0 且 last_seen 早于 minAge 的一律清理。刚 Put 还没来得及
|
||||
// AddRef 的项 refcount 也是 0,minAge 保护它们不被立刻清掉。
|
||||
// 2. 清完仍超 maxBytes 时,继续按 last_seen 从旧到新淘汰 ref_count=0 的项。
|
||||
//
|
||||
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,正是本包要避免的。
|
||||
func (s *Store) GC(minAge time.Duration) (removed int, freed int64, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-minAge)
|
||||
rows, err := s.db.Query(`
|
||||
SELECT digest, size FROM media
|
||||
WHERE ref_count <= 0 AND last_seen < ?
|
||||
ORDER BY last_seen`, cutoff)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
type cand struct {
|
||||
digest string
|
||||
size int64
|
||||
}
|
||||
var cands []cand
|
||||
for rows.Next() {
|
||||
var c cand
|
||||
if err := rows.Scan(&c.digest, &c.size); err == nil {
|
||||
cands = append(cands, c)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, c := range cands {
|
||||
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
|
||||
continue // 删不掉就留着元数据,下轮再试;不制造"元数据没了文件还在"的孤儿
|
||||
}
|
||||
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
freed += c.size
|
||||
}
|
||||
|
||||
if s.maxBytes > 0 {
|
||||
r2, f2 := s.enforceCapacityLocked()
|
||||
removed += r2
|
||||
freed += f2
|
||||
}
|
||||
return removed, freed, nil
|
||||
}
|
||||
|
||||
// enforceCapacityLocked 在超出 maxBytes 时继续淘汰无引用项(调用方已持锁)。
|
||||
func (s *Store) enforceCapacityLocked() (removed int, freed int64) {
|
||||
var total int64
|
||||
if err := s.db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM media`).Scan(&total); err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
if total <= s.maxBytes {
|
||||
return 0, 0
|
||||
}
|
||||
need := total - s.maxBytes
|
||||
|
||||
rows, err := s.db.Query(`SELECT digest, size FROM media WHERE ref_count <= 0 ORDER BY last_seen`)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
type cand struct {
|
||||
digest string
|
||||
size int64
|
||||
}
|
||||
var cands []cand
|
||||
for rows.Next() {
|
||||
var c cand
|
||||
if err := rows.Scan(&c.digest, &c.size); err == nil {
|
||||
cands = append(cands, c)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, c := range cands {
|
||||
if freed >= need {
|
||||
break
|
||||
}
|
||||
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
|
||||
continue
|
||||
}
|
||||
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
freed += c.size
|
||||
}
|
||||
return removed, freed
|
||||
}
|
||||
|
||||
// Stats 返回容量与条目统计,供 WebUI / healthcheck 展示。
|
||||
// Stats 返回条目统计,供 WebUI / healthcheck 展示。
|
||||
func (s *Store) Stats() map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
out := map[string]interface{}{"blob_dir": s.blobDir, "max_bytes": s.maxBytes}
|
||||
var count, described, orphan int
|
||||
out := map[string]interface{}{"blob_dir": s.blobDir}
|
||||
var count int
|
||||
var total int64
|
||||
s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total)
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE COALESCE(description,'') != ''`).Scan(&described)
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE ref_count <= 0`).Scan(&orphan)
|
||||
out["count"] = count
|
||||
out["total_bytes"] = total
|
||||
out["described"] = described
|
||||
out["unreferenced"] = orphan
|
||||
|
||||
byKind := map[string]int{}
|
||||
rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`)
|
||||
@ -609,6 +289,202 @@ func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// ---- 多模态嵌入(v1.2.0) ----
|
||||
|
||||
// SetVec 给一条已入库的媒体设置视觉嵌入向量。
|
||||
//
|
||||
// 设计选择:vec 是 TEXT(JSON 序列化的 []float64)而非 BLOB,
|
||||
// 因为 Go 的 json.Marshal/Unmarshal 对 []float64 是自然的,
|
||||
// 而 SQLite 的 BLOB 是 []byte,序列化多一层反而复杂。
|
||||
// 量级:一条 vec 最多 1536 维 × ~15 字节 ≈ 23KB,TEXT 合适。
|
||||
func (s *Store) SetVec(digest string, vec []float64, model string) error {
|
||||
vecJSON, err := json.Marshal(vec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: marshal vec: %w", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err = s.db.Exec(`UPDATE media SET vec = ?, vec_model = ? WHERE digest = ?`,
|
||||
string(vecJSON), model, digest)
|
||||
return err
|
||||
}
|
||||
|
||||
// StaleVecDigests 返回所有需要重新嵌入的图片 digest:
|
||||
// vec_model 不等于 currentModel(模型切换)或 vec_model 为空(从未嵌入)。
|
||||
// 调用方使用返回的 digest 列表调用 Get/EmbedImage/SetVec 完成重算。
|
||||
func (s *Store) StaleVecDigests(currentModel string) ([]string, error) {
|
||||
return s.staleVecDigests(currentModel, "image")
|
||||
}
|
||||
|
||||
// StaleVecDigestsAll 返回所有需要重新嵌入的媒体 digest(不限 kind),
|
||||
// 供模型切换后全量迁移向量空间(image + audio + video 等)。
|
||||
func (s *Store) StaleVecDigestsAll(currentModel string) ([]string, error) {
|
||||
return s.staleVecDigests(currentModel, "")
|
||||
}
|
||||
|
||||
// staleVecDigests 是 StaleVecDigests 的核心实现,kind=” 时不按 kind 过滤。
|
||||
// 废弃了"只迁移图片"的限定:模型切换后所有模态都应迁移到新向量空间。
|
||||
func (s *Store) staleVecDigests(currentModel string, kind string) ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
query := `
|
||||
SELECT digest FROM media
|
||||
WHERE (COALESCE(vec_model,'') = '' OR vec_model != ?)`
|
||||
if kind != "" {
|
||||
query += ` AND kind = ?`
|
||||
}
|
||||
query += ` ORDER BY last_seen`
|
||||
|
||||
var args []interface{}
|
||||
args = append(args, currentModel)
|
||||
if kind != "" {
|
||||
args = append(args, kind)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var digests []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err == nil {
|
||||
digests = append(digests, d)
|
||||
}
|
||||
}
|
||||
return digests, rows.Err()
|
||||
}
|
||||
|
||||
// QueryMedia 用查询向量对所有已嵌入媒体做余弦相似度检索,返回 topK 个最相似的 Item。
|
||||
//
|
||||
// 这是跨模态检索的关键:查询可以是图片也可以是文本(经文本向量化后调用此方法),
|
||||
// 被查的媒体库里的每个 item 也有一个视觉向量。两者在同一空间比对,
|
||||
// 谁的相似度更高就召回谁——不再区分「这是一张图的查询」还是「这是一段文字的查询」,
|
||||
// 由向量空间的相似度自动判断。
|
||||
func (s *Store) QueryMedia(queryVec []float64, model string, topK int) ([]*Item, error) {
|
||||
hits, err := s.QueryMediaScored(queryVec, model, topK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hits == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]*Item, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Item
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MediaHit 是一条媒体相似度候选及其分数。
|
||||
// 跨模态融合需要原始分数做归一化,仅返回 Item 会丢掉尺度信息。
|
||||
type MediaHit struct {
|
||||
Item *Item
|
||||
Score float64
|
||||
}
|
||||
|
||||
// QueryMediaScored 用查询向量对所有已嵌入媒体做余弦相似度检索,
|
||||
// 返回 topK 个最相似的候选及其原始 cosine 分数(供跨模态归一化)。
|
||||
//
|
||||
// 分数只做排序,不在存储层设绝对阈值:多模态文本→图像的绝对 cosine 随模型、
|
||||
// 语言与数据域漂移,真实标定中有效命中可以低至 0.015。相关性门控在融合器中
|
||||
// 使用当前候选集合的相对分布完成。
|
||||
func (s *Store) QueryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
|
||||
return s.queryMediaScored(queryVec, model, topK)
|
||||
}
|
||||
|
||||
func (s *Store) queryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
|
||||
if topK <= 0 {
|
||||
topK = 20
|
||||
}
|
||||
if len(queryVec) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
query := `SELECT digest, kind, mime, size, width, height,
|
||||
origin_path, tool, first_seen, last_seen,
|
||||
vec, vec_model
|
||||
FROM media WHERE vec IS NOT NULL AND vec != ''`
|
||||
var args []interface{}
|
||||
if model != "" {
|
||||
query += ` AND vec_model = ?`
|
||||
args = append(args, model)
|
||||
}
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type scored struct {
|
||||
item *Item
|
||||
score float64
|
||||
}
|
||||
var candidates []scored
|
||||
for rows.Next() {
|
||||
var it Item
|
||||
var kind string
|
||||
var origin, tool, vecJSON, vecModel sql.NullString
|
||||
if err := rows.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
|
||||
&origin, &tool, &it.FirstSeen, &it.LastSeen,
|
||||
&vecJSON, &vecModel); err != nil {
|
||||
continue
|
||||
}
|
||||
it.Kind = Kind(kind)
|
||||
it.OriginPath = origin.String
|
||||
it.Tool = tool.String
|
||||
if !vecJSON.Valid || vecJSON.String == "" {
|
||||
continue
|
||||
}
|
||||
var itemVec []float64
|
||||
if err := json.Unmarshal([]byte(vecJSON.String), &itemVec); err != nil || len(itemVec) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(itemVec) != len(queryVec) {
|
||||
continue // 维度不一致,跳过
|
||||
}
|
||||
score := cosineSimilaritySlice(queryVec, itemVec)
|
||||
if score > 0.05 {
|
||||
candidates = append(candidates, scored{&it, score})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 按分数降序排序
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].score > candidates[j].score
|
||||
})
|
||||
if len(candidates) > topK {
|
||||
candidates = candidates[:topK]
|
||||
}
|
||||
out := make([]MediaHit, len(candidates))
|
||||
for i, c := range candidates {
|
||||
out[i] = MediaHit{Item: c.item, Score: c.score}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cosineSimilaritySlice 计算两个 []float64 向量的余弦相似度。
|
||||
func cosineSimilaritySlice(a, b []float64) float64 {
|
||||
var dot, normA, normB float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
normA += a[i] * a[i]
|
||||
normB += b[i] * b[i]
|
||||
}
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
|
||||
// ---- 扫描辅助 ----
|
||||
|
||||
type rowScanner interface {
|
||||
@ -628,16 +504,22 @@ func (s *Store) scanRows(r rowScanner) (*Item, error) { return scanItem(r) }
|
||||
func scanItem(r rowScanner) (*Item, error) {
|
||||
var it Item
|
||||
var kind string
|
||||
var origin, tool, desc, by sql.NullString
|
||||
var origin, tool, vecJSON, vecModel sql.NullString
|
||||
if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
|
||||
&origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen); err != nil {
|
||||
&origin, &tool, &it.FirstSeen, &it.LastSeen,
|
||||
&vecJSON, &vecModel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.Kind = Kind(kind)
|
||||
it.OriginPath = origin.String
|
||||
it.Tool = tool.String
|
||||
it.Description = desc.String
|
||||
it.DescribedBy = by.String
|
||||
if vecJSON.Valid && vecJSON.String != "" {
|
||||
var v []float64
|
||||
if err := json.Unmarshal([]byte(vecJSON.String), &v); err == nil {
|
||||
it.Vec = v
|
||||
}
|
||||
}
|
||||
it.VecModel = vecModel.String
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
|
||||
@ -5,12 +5,13 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T, maxBytes int64) *Store {
|
||||
// newTestStore 建一个临时媒体存储。
|
||||
// 参数保留只为兼容旧调用点;媒体不再有容量上限(生命周期由记忆块决定)。
|
||||
func newTestStore(t *testing.T, _ ...int64) *Store {
|
||||
t.Helper()
|
||||
s, err := New(t.TempDir(), maxBytes)
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
@ -109,219 +110,45 @@ func TestPut_NoPartialBlobOnDisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefCount_AddIsIdempotent(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||||
func TestDelete_RemovesContentAndMetadata(t *testing.T) {
|
||||
// 删除块即删除内容:Delete 同时清掉 blob 与元数据。
|
||||
// 这不是 GC,也不看引用计数——调用方是记忆系统本身。
|
||||
s := newTestStore(t)
|
||||
d, _ := s.Put([]byte("held"), Item{MIME: "image/png"})
|
||||
other, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := s.AddRef(d, "context", "evt-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
it, _ := s.Stat(d)
|
||||
// 重复 AddRef 若都递增,计数会虚高,GC 永远不敢清。
|
||||
if it.RefCount != 1 {
|
||||
t.Fatalf("同一 owner 重复 AddRef 应只计 1,实际 %d", it.RefCount)
|
||||
}
|
||||
|
||||
if err := s.AddRef(d, "document", "doc-9"); err != nil {
|
||||
if err := s.Delete(other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, _ = s.Stat(d)
|
||||
if it.RefCount != 2 {
|
||||
t.Fatalf("不同 owner 应各计一次,实际 %d", it.RefCount)
|
||||
if _, err := s.Stat(other); err == nil {
|
||||
t.Fatal("删除后元数据应已移除")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefCount_DropAndNeverNegative(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||||
s.AddRef(d, "context", "e1")
|
||||
|
||||
if err := s.DropRef(d, "context", "e1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, _ := s.Stat(d)
|
||||
if it.RefCount != 0 {
|
||||
t.Fatalf("应归零,实际 %d", it.RefCount)
|
||||
}
|
||||
|
||||
// 多余的 DropRef 不该把计数压成负数(负数会让容量 GC 的排序失去意义)
|
||||
for i := 0; i < 3; i++ {
|
||||
s.DropRef(d, "context", "e1")
|
||||
}
|
||||
it, _ = s.Stat(d)
|
||||
if it.RefCount != 0 {
|
||||
t.Fatalf("重复 DropRef 后仍应为 0,实际 %d", it.RefCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropOwner_RemovesAllItsRefs(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
d1, _ := s.Put([]byte("frame1"), Item{MIME: "image/jpeg"})
|
||||
d2, _ := s.Put([]byte("frame2"), Item{MIME: "image/jpeg"})
|
||||
s.AddRef(d1, "context", "evt-x")
|
||||
s.AddRef(d2, "context", "evt-x")
|
||||
s.AddRef(d1, "document", "doc-y") // 别的 owner 也引了 d1
|
||||
|
||||
n, err := s.DropOwner("context", "evt-x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("应注销 2 条引用,实际 %d", n)
|
||||
}
|
||||
|
||||
it1, _ := s.Stat(d1)
|
||||
it2, _ := s.Stat(d2)
|
||||
if it1.RefCount != 1 {
|
||||
t.Fatalf("d1 仍被 document 引用,应剩 1,实际 %d", it1.RefCount)
|
||||
}
|
||||
if it2.RefCount != 0 {
|
||||
t.Fatalf("d2 应归零,实际 %d", it2.RefCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefs_ListsOwnerDigests(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
|
||||
d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
|
||||
s.AddRef(d1, "context", "e1")
|
||||
s.AddRef(d2, "context", "e1")
|
||||
|
||||
got, err := s.Refs("context", "e1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("应返回 2 个 digest,实际 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGC_KeepsReferencedContent(t *testing.T) {
|
||||
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,
|
||||
// 正是本包要避免的。
|
||||
s := newTestStore(t, 0)
|
||||
kept, _ := s.Put([]byte("referenced"), Item{MIME: "image/png"})
|
||||
orphan, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"})
|
||||
s.AddRef(kept, "context", "e1")
|
||||
|
||||
// minAge=0 让刚 Put 的都算超龄
|
||||
removed, _, err := s.GC(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("应只清 1 条无引用项,实际 %d", removed)
|
||||
}
|
||||
if _, err := s.Get(kept); err != nil {
|
||||
t.Fatalf("被引用的内容不该被清: %v", err)
|
||||
}
|
||||
if _, err := s.Stat(orphan); err == nil {
|
||||
t.Fatal("无引用项的元数据应已删除")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGC_MinAgeProtectsFreshUnreferenced(t *testing.T) {
|
||||
// 刚 Put 还没来得及 AddRef 的项 refcount 也是 0;
|
||||
// minAge 必须保护它们,否则「Put 完还没挂上就被 GC 清掉」。
|
||||
s := newTestStore(t, 0)
|
||||
d, _ := s.Put([]byte("just-arrived"), Item{MIME: "image/png"})
|
||||
|
||||
removed, _, err := s.GC(time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 0 {
|
||||
t.Fatalf("新入库项应被 minAge 保护,却清掉了 %d 条", removed)
|
||||
if _, err := s.Get(other); err == nil {
|
||||
t.Fatal("删除后内容应已移除")
|
||||
}
|
||||
// 未被删除的项不受影响
|
||||
if _, err := s.Get(d); err != nil {
|
||||
t.Fatalf("内容应还在: %v", err)
|
||||
t.Fatalf("未删除的内容不该受影响: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGC_EnforcesCapacity(t *testing.T) {
|
||||
// 容量上限:清完超龄项后仍超限,继续按 last_seen 从旧到新淘汰无引用项。
|
||||
blob := make([]byte, 1024)
|
||||
s := newTestStore(t, 2048) // 只容 2KB
|
||||
|
||||
var digests []string
|
||||
for i := 0; i < 4; i++ {
|
||||
b := append([]byte{byte(i)}, blob...) // 内容各异,避免去重
|
||||
d, err := s.Put(b, Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digests = append(digests, d)
|
||||
time.Sleep(2 * time.Millisecond) // 拉开 last_seen
|
||||
func TestDelete_UnknownDigestIsNoop(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if err := s.Delete(""); err != nil {
|
||||
t.Fatalf("空 digest 应为无操作: %v", err)
|
||||
}
|
||||
|
||||
// 保护最后一个,确认容量 GC 也不碰有引用的
|
||||
s.AddRef(digests[3], "context", "e1")
|
||||
|
||||
removed, freed, err := s.GC(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed == 0 {
|
||||
t.Fatal("超限应触发淘汰")
|
||||
}
|
||||
if _, err := s.Get(digests[3]); err != nil {
|
||||
t.Fatalf("有引用项即使超限也不该删: %v", err)
|
||||
}
|
||||
t.Logf("removed=%d freed=%d", removed, freed)
|
||||
|
||||
st := s.Stats()
|
||||
if total := st["total_bytes"].(int64); total > 2048 {
|
||||
// 有引用项可能让总量降不到线下,这是刻意的(宁可超限也不断引用)
|
||||
t.Logf("总量 %d 仍超 2048,因有引用项不可删(预期行为)", total)
|
||||
if err := s.Delete("ffffffffffffffff"); err != nil {
|
||||
t.Fatalf("不存在的 digest 应为无操作: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribe_OverwritesExplicitly(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||||
|
||||
if err := s.Describe(d, "一只橘猫", "vis-a"); err != nil {
|
||||
func TestDescribe_Removed(t *testing.T) {
|
||||
// 媒体不再有文字描述:描述式索引是废弃的就机制。
|
||||
// 这里只保留一个编译期断言,确保 API 不会静默回归。
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Put([]byte("img"), Item{MIME: "image/png"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, _ := s.Stat(d)
|
||||
if it.Description != "一只橘猫" || it.DescribedBy != "vis-a" {
|
||||
t.Fatalf("描述未写入: %+v", it)
|
||||
}
|
||||
|
||||
// Describe 是显式操作,允许覆盖(换更强模型重描述)
|
||||
if err := s.Describe(d, "一只橘色虎斑猫坐在窗台", "vis-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, _ = s.Stat(d)
|
||||
if !strings.Contains(it.Description, "虎斑") || it.DescribedBy != "vis-b" {
|
||||
t.Fatalf("Describe 应覆盖旧描述: %+v", it)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribe_UnknownDigestErrors(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
err := s.Describe("deadbeef", "x", "y")
|
||||
if err == nil {
|
||||
t.Fatal("未知 digest 应报错而非静默成功")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPut_DoesNotClobberExistingDescription(t *testing.T) {
|
||||
// 先到的描述可能来自更强的模型;后到的空值不该把它冲掉。
|
||||
s := newTestStore(t, 0)
|
||||
data := []byte("img")
|
||||
d, _ := s.Put(data, Item{MIME: "image/png", Description: "详细描述", DescribedBy: "strong-model"})
|
||||
|
||||
// 第二次 Put 同内容但不带描述
|
||||
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
it, _ := s.Stat(d)
|
||||
if it.Description != "详细描述" || it.DescribedBy != "strong-model" {
|
||||
t.Fatalf("重复 Put 的空描述不该冲掉已有描述: %+v", it)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPut_BackfillsMissingDimensions(t *testing.T) {
|
||||
@ -342,47 +169,22 @@ func TestPut_BackfillsMissingDimensions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_FiltersByDescriptionAndKind(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
di, _ := s.Put([]byte("chart-img"), Item{MIME: "image/png"})
|
||||
da, _ := s.Put([]byte("speech-aud"), Item{MIME: "audio/wav"})
|
||||
dn, _ := s.Put([]byte("no-desc"), Item{MIME: "image/png"})
|
||||
s.Describe(di, "一张蓝色的柱状图表", "vis")
|
||||
s.Describe(da, "一段关于图表的讲解录音", "aud")
|
||||
func TestStats_CountsByKind(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
s.Put([]byte("i1"), Item{MIME: "image/png"})
|
||||
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
|
||||
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
|
||||
|
||||
all, err := s.Search("图表", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
st := s.Stats()
|
||||
if st["count"].(int) != 3 {
|
||||
t.Fatalf("count 应为 3,实际 %v", st["count"])
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("两条描述都含「图表」,应返回 2,实际 %d", len(all))
|
||||
if _, ok := st["described"]; ok {
|
||||
t.Fatal("媒体已不再有描述计数")
|
||||
}
|
||||
|
||||
imgs, _ := s.Search("图表", KindImage, 10)
|
||||
if len(imgs) != 1 || imgs[0].Digest != di {
|
||||
t.Fatalf("按 image 过滤应只剩图片,实际 %d 条", len(imgs))
|
||||
}
|
||||
|
||||
// 无描述的项不该出现在语义检索结果里
|
||||
for _, it := range all {
|
||||
if it.Digest == dn {
|
||||
t.Fatal("无描述的项不该被 Search 返回")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPending_ReturnsUndescribed(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
described, _ := s.Put([]byte("has-desc"), Item{MIME: "image/png"})
|
||||
undescribed, _ := s.Put([]byte("needs-desc"), Item{MIME: "image/png"})
|
||||
s.Describe(described, "已有描述", "vis")
|
||||
|
||||
pending, err := s.Pending(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].Digest != undescribed {
|
||||
t.Fatalf("应只返回未描述项,实际 %d 条", len(pending))
|
||||
byKind := st["by_kind"].(map[string]int)
|
||||
if byKind["image"] != 2 || byKind["audio"] != 1 {
|
||||
t.Fatalf("by_kind 不对: %v", byKind)
|
||||
}
|
||||
}
|
||||
|
||||
@ -414,32 +216,8 @@ func TestParseDataURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats_CountsByKindAndDescription(t *testing.T) {
|
||||
s := newTestStore(t, 4096)
|
||||
d1, _ := s.Put([]byte("i1"), Item{MIME: "image/png"})
|
||||
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
|
||||
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
|
||||
s.Describe(d1, "描述", "vis")
|
||||
s.AddRef(d1, "context", "e1")
|
||||
|
||||
st := s.Stats()
|
||||
if st["count"].(int) != 3 {
|
||||
t.Fatalf("count 应为 3,实际 %v", st["count"])
|
||||
}
|
||||
if st["described"].(int) != 1 {
|
||||
t.Fatalf("described 应为 1,实际 %v", st["described"])
|
||||
}
|
||||
if st["unreferenced"].(int) != 2 {
|
||||
t.Fatalf("unreferenced 应为 2,实际 %v", st["unreferenced"])
|
||||
}
|
||||
byKind := st["by_kind"].(map[string]int)
|
||||
if byKind["image"] != 2 || byKind["audio"] != 1 {
|
||||
t.Fatalf("by_kind 不对: %v", byKind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPut_RejectsEmpty(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil {
|
||||
t.Fatal("空内容应报错")
|
||||
}
|
||||
@ -448,16 +226,15 @@ func TestPut_RejectsEmpty(t *testing.T) {
|
||||
func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
||||
// 记忆的意义就在于跨重启还在。
|
||||
dir := t.TempDir()
|
||||
s1, err := New(dir, 0)
|
||||
s1, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"})
|
||||
s1.Describe(d, "跨重启的描述", "vis")
|
||||
s1.AddRef(d, "context", "e1")
|
||||
s1.SetVec(d, []float64{0.1, 0.2}, "test-space")
|
||||
s1.Close()
|
||||
|
||||
s2, err := New(dir, 0)
|
||||
s2, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -467,8 +244,8 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("重开后应能查到: %v", err)
|
||||
}
|
||||
if it.Description != "跨重启的描述" || it.RefCount != 1 {
|
||||
t.Fatalf("元数据应持久化: %+v", it)
|
||||
if it.OriginPath != "/tmp/x.png" || len(it.Vec) != 2 || it.VecModel != "test-space" {
|
||||
t.Fatalf("元数据与向量应持久化: %+v", it)
|
||||
}
|
||||
data, err := s2.Get(d)
|
||||
if err != nil || string(data) != "persistent-img" {
|
||||
@ -476,38 +253,26 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) {
|
||||
// 「已尝试但无法描述」的项必须退出待描述队列。
|
||||
//
|
||||
// 这些项被标记为 described_by=unsupported/content-missing 而 description
|
||||
// 仍为空。若 Pending 只看 description,它们每轮都会被取出来重试、
|
||||
// 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
|
||||
s := newTestStore(t, 0)
|
||||
func TestStaleVecDigests_TracksModelSwitch(t *testing.T) {
|
||||
// 模型切换后旧向量必须被重算:StaleVecDigests 是启动迁移的入口。
|
||||
s := newTestStore(t)
|
||||
d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
|
||||
d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
|
||||
s.SetVec(d1, []float64{0.1}, "space-a")
|
||||
|
||||
fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"})
|
||||
unsupported, _ := s.Put([]byte("cannot-describe"), Item{MIME: "application/octet-stream"})
|
||||
described, _ := s.Put([]byte("已描述"), Item{MIME: "image/png"})
|
||||
|
||||
// 标记「尝试过但不支持」:description 空,described_by 非空
|
||||
if err := s.Describe(unsupported, "", "unsupported"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Describe(described, "一张图", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pending, err := s.Pending(10)
|
||||
stale, err := s.StaleVecDigestsAll("space-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 {
|
||||
var names []string
|
||||
for _, p := range pending {
|
||||
names = append(names, shortDigest(p.Digest))
|
||||
}
|
||||
t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names)
|
||||
if len(stale) != 1 || stale[0] != d2 {
|
||||
t.Fatalf("只有未嵌入的 d2 需重算,实际 %v", stale)
|
||||
}
|
||||
if pending[0].Digest != fresh {
|
||||
t.Fatalf("待描述的应是未处理项,实际 %s", shortDigest(pending[0].Digest))
|
||||
|
||||
stale, err = s.StaleVecDigestsAll("space-b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stale) != 2 {
|
||||
t.Fatalf("换空间后两条都需重算,实际 %v", stale)
|
||||
}
|
||||
}
|
||||
|
||||
176
internal/memory/media/media_vec_test.go
Normal file
176
internal/memory/media/media_vec_test.go
Normal file
@ -0,0 +1,176 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQueryMedia_BasicSimilarity(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
defer s.Close()
|
||||
|
||||
// 入库三张带向量的媒体:两张图、一段音频
|
||||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||||
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/jpeg"})
|
||||
d3, _ := s.Put([]byte("aud1"), Item{MIME: "audio/wav"})
|
||||
|
||||
// 模拟视觉嵌入:img1 和 img2 向量接近,aud1 远离
|
||||
vec1 := []float64{0.9, 0.1, 0.0, 0.0}
|
||||
vec2 := []float64{0.8, 0.2, 0.0, 0.0} // 与 vec1 相似
|
||||
vec3 := []float64{0.0, 0.0, 0.9, 0.1} // 与前两个完全不同
|
||||
|
||||
s.SetVec(d1, vec1, "test-clip")
|
||||
s.SetVec(d2, vec2, "test-clip")
|
||||
s.SetVec(d3, vec3, "test-clip")
|
||||
|
||||
// 用 vec1 作为查询:vec2 最相似,vec3 与 vec1 正交(相似度 0,被阈值过滤)
|
||||
results, err := s.QueryMedia(vec1, "test-clip", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// vec3 与 vec1 正交(余弦相似度 0),被 0.05 阈值正确剔除 → 只召回 2 个
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results (正交的 aud1 被阈值过滤), got %d", len(results))
|
||||
}
|
||||
// 第一个应该是 img2(0.9 vs d1 的 1.0?不,这里算清楚)
|
||||
// vec1·vec2 与 vec1·vec1 比较:
|
||||
// sim(vec1,vec1) = 1.0(img1 与自身),sim(vec1,vec2) = 0.9*0.8+0.1*0.2 = 0.74
|
||||
// 所以 img1(自相似 1.0)排第一,img2 排第二
|
||||
if results[0].Digest != d1 {
|
||||
t.Errorf("expected d1 (自相似 1.0) as first, got %s", results[0].Digest)
|
||||
}
|
||||
if results[1].Digest != d2 {
|
||||
t.Errorf("expected d2 as second, got %s", results[1].Digest)
|
||||
}
|
||||
|
||||
// 验证分数:img1 与自身是 1.0
|
||||
selfScore := cosineSimilaritySlice(vec1, vec1)
|
||||
if math.Abs(selfScore-1.0) > 1e-10 {
|
||||
t.Errorf("self-similarity should be 1.0, got %f", selfScore)
|
||||
}
|
||||
|
||||
// img1 与 aud1 的相似度应该很低
|
||||
crossScore := cosineSimilaritySlice(vec1, vec3)
|
||||
if crossScore > 0.1 {
|
||||
t.Errorf("cross-modality similarity should be low, got %f", crossScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryMedia_EmptyVecSkipped(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
defer s.Close()
|
||||
|
||||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||||
_, _ = s.Put([]byte("img2"), Item{MIME: "image/png"})
|
||||
|
||||
// d1 有向量,d2 没有
|
||||
s.SetVec(d1, []float64{0.5, 0.5}, "test")
|
||||
// d2 留空
|
||||
|
||||
results, err := s.QueryMedia([]float64{0.5, 0.5}, "test", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result (d2 has no vec), got %d", len(results))
|
||||
}
|
||||
if results[0].Digest != d1 {
|
||||
t.Errorf("expected d1, got %s", results[0].Digest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryMedia_DimensionMismatchSkipped(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
defer s.Close()
|
||||
|
||||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||||
s.SetVec(d1, []float64{0.5, 0.5}, "model-A") // 2 维
|
||||
|
||||
// 查询用 3 维向量:维度不匹配,应该返回空
|
||||
results, err := s.QueryMedia([]float64{0.3, 0.3, 0.3}, "model-A", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("expected 0 results (dim mismatch), got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryMedia_EmptyQueryReturnsNil(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
defer s.Close()
|
||||
|
||||
results, err := s.QueryMedia(nil, "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if results != nil {
|
||||
t.Fatalf("expected nil, got %d results", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVec_PersistsCorrectly(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
defer s.Close()
|
||||
|
||||
d, _ := s.Put([]byte("hello"), Item{MIME: "image/png"})
|
||||
vec := []float64{0.1, 0.2, 0.3, 0.4}
|
||||
s.SetVec(d, vec, "clip-vit-b32")
|
||||
|
||||
it, err := s.Stat(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if it.VecModel != "clip-vit-b32" {
|
||||
t.Errorf("VecModel = %q, want clip-vit-b32", it.VecModel)
|
||||
}
|
||||
if len(it.Vec) != 4 {
|
||||
t.Fatalf("Vec len = %d, want 4", len(it.Vec))
|
||||
}
|
||||
for i, v := range vec {
|
||||
if math.Abs(it.Vec[i]-v) > 1e-10 {
|
||||
t.Errorf("Vec[%d] = %f, want %f", i, it.Vec[i], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleVecDigests(t *testing.T) {
|
||||
s := newTestStore(t, 0)
|
||||
defer s.Close()
|
||||
|
||||
// 有向量且 vec_model 匹配 → 非 stale
|
||||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||||
s.SetVec(d1, []float64{0.1}, "space-a")
|
||||
|
||||
// 有向量但 vec_model 旧 → stale
|
||||
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/png"})
|
||||
s.SetVec(d2, []float64{0.2}, "space-old")
|
||||
|
||||
// 从未嵌入(vec_model 空)→ stale
|
||||
d3, _ := s.Put([]byte("img3"), Item{MIME: "image/png"})
|
||||
|
||||
// 与向量/描述无关的图片同样应被迁移:图片独立参与向量空间
|
||||
d4, _ := s.Put([]byte("img4"), Item{MIME: "image/png"})
|
||||
|
||||
// 音频不参与图片迁移(StaleVecDigests 只查 kind='image')
|
||||
s.Put([]byte("aud1"), Item{MIME: "audio/wav"})
|
||||
|
||||
stale, err := s.StaleVecDigests("space-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// d1 匹配模型 → 非 stale;d2 旧模型 + d3 未嵌入 + d4 无描述图片 = 3 stale;aud1 不算
|
||||
if len(stale) != 3 {
|
||||
t.Fatalf("expected 3 stale digests (d2 旧模型 + d3 未嵌入 + d4 无描述), got %d: %v", len(stale), stale)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, d := range stale {
|
||||
got[d] = true
|
||||
}
|
||||
if !got[d2] || !got[d3] || !got[d4] {
|
||||
t.Errorf("expected d2, d3, d4 stale, got %v", stale)
|
||||
}
|
||||
if got[d1] {
|
||||
t.Errorf("d1 (匹配模型) 不应 stale")
|
||||
}
|
||||
}
|
||||
@ -11,19 +11,12 @@ import (
|
||||
|
||||
// 冒烟测试:走真实数据路径的端到端场景,而非孤立的 API 单测。
|
||||
//
|
||||
// 之前这套场景是 internal/memory/media/smoke/ 下一个带 //go:build smoke 的
|
||||
// 独立 main,得记着加 -tags smoke 才跑得到——那种早晚会被忘掉。搬成普通
|
||||
// 测试后它随 go test ./... 一起跑,冒烟的意义(每次改动都过一遍真实链路)
|
||||
// 才真正成立。
|
||||
// 媒体存储现在只做内容寻址(CAS):字节 + 元数据 + 向量。
|
||||
// “哪些字节还活着”由三层记忆持有的一等记忆块决定,调用方把该集合传给
|
||||
// GC/检索,本层不维护 media_refs/ref_count 这类平行账本。
|
||||
|
||||
// makePNG 生成一张 w×h 的条带 PNG,用真 PNG 而不是随机字节,
|
||||
// 让入库/回读/digest 走的是与生产一致的数据形态。
|
||||
//
|
||||
// variant 注入到像素而不只用于选色:最初写的是
|
||||
// palette[(variant+y*3/h)%5],调色盘只 5 色,于是 variant=0 与 5 产出
|
||||
// 逐字节相同的 PNG——冒烟跑出「6 帧只得 5 条」,看着像存储丢了一帧,
|
||||
// 实际是 CAS 正确去重了两张真同图。冒烟要验的是「不同帧各存一份」,
|
||||
// 夹具就必须保证帧间真的不同。
|
||||
func makePNG(w, h, variant int) []byte {
|
||||
palette := [][3]byte{
|
||||
{255, 0, 0}, {0, 192, 0}, {0, 0, 255}, {255, 220, 0}, {160, 0, 200},
|
||||
@ -71,7 +64,7 @@ func makePNG(w, h, variant int) []byte {
|
||||
|
||||
func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
|
||||
// 场景:用户连问几轮同一张截图。multimodal 每轮都会重新注入,
|
||||
// 磁盘上应该只有一份,但每轮的 context 事件各持一个引用。
|
||||
// 内容寻址天然去重,磁盘上只应有一份。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
png := makePNG(400, 400, 0)
|
||||
|
||||
@ -96,9 +89,6 @@ func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
|
||||
} else if d != d0 {
|
||||
t.Fatalf("同一张图第 %d 轮 digest 变了", turn)
|
||||
}
|
||||
if err := s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn)); err != nil {
|
||||
t.Fatalf("第 %d 轮 AddRef: %v", turn, err)
|
||||
}
|
||||
}
|
||||
|
||||
st := s.Stats()
|
||||
@ -108,18 +98,16 @@ func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
|
||||
if total := st["total_bytes"].(int64); total != int64(len(png)) {
|
||||
t.Fatalf("字节数应等于单张原图 %d,实际 %d", len(png), total)
|
||||
}
|
||||
it, _ := s.Stat(d0)
|
||||
if it.RefCount != 5 {
|
||||
t.Fatalf("应有 5 个引用,实际 %d", it.RefCount)
|
||||
// 三层记忆持有它;内容应仍可读
|
||||
if _, err := s.Get(d0); err != nil {
|
||||
t.Fatalf("内容应仍可读: %v", err)
|
||||
}
|
||||
t.Logf("同图 5 轮:条目=1 字节=%d refcount=%d", len(png), it.RefCount)
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_VideoFramesDistinct(t *testing.T) {
|
||||
// 场景:see_video 抽 6 帧,帧间内容不同,应各存一份并共享一个 owner。
|
||||
// 场景:see_video 抽 6 帧,帧间内容不同,应各存一份。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
var frames []string
|
||||
keep := map[string]bool{}
|
||||
for i := 0; i < 6; i++ {
|
||||
d, err := s.Put(makePNG(320, 240, i), Item{
|
||||
MIME: "image/jpeg", Width: 320, Height: 240, Tool: "multimodal_see_video",
|
||||
@ -127,160 +115,133 @@ func TestSmoke_VideoFramesDistinct(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 帧: %v", i, err)
|
||||
}
|
||||
frames = append(frames, d)
|
||||
if err := s.AddRef(d, "context", "evt-video"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep[d] = true
|
||||
}
|
||||
|
||||
st := s.Stats()
|
||||
if st["count"].(int) != 6 {
|
||||
if st := s.Stats(); st["count"].(int) != 6 {
|
||||
t.Fatalf("6 帧应各存一份,实际 %v 条", st["count"])
|
||||
}
|
||||
refs, err := s.Refs("context", "evt-video")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 6 {
|
||||
t.Fatalf("evt-video 应引用 6 帧,实际 %d", len(refs))
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_DescribeThenRetrieve(t *testing.T) {
|
||||
// 场景 C:视觉模型描述落库后,描述文字成为可检索的语义入口。
|
||||
// 这是本方案最关键的一环——blob 可能被淘汰,描述会长期留在记忆里。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
func TestSmoke_NearestNeighborVectorRetrieve(t *testing.T) {
|
||||
// 场景 C:图片只按自己的原生向量被检索。
|
||||
// 没有描述文本参与——描述式索引是废弃的就机制。
|
||||
s := newTestStore(t)
|
||||
|
||||
pic, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png", Tool: "multimodal_see_picture"})
|
||||
if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetVec(pic, []float64{1, 0, 0, 0}, "space")
|
||||
var frames []string
|
||||
for i := 0; i < 6; i++ {
|
||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
||||
s.SetVec(d, []float64{1, 1, float64(i) / 10, 0}, "space")
|
||||
frames = append(frames, d)
|
||||
if err := s.Describe(d, fmt.Sprintf("视频第 %d 帧:测试图卡,含彩条与计数器", i+1), "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hits, err := s.QueryMediaScored([]float64{1, 0, 0, 0}, "space", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 7 {
|
||||
t.Fatalf("7 份媒体都有同空间向量,应全部可召,实际 %d", len(hits))
|
||||
}
|
||||
if hits[0].Item.Digest != pic {
|
||||
t.Fatalf("与查询同向的应是第一命中,实际 %s", shortDigest(hits[0].Item.Digest))
|
||||
}
|
||||
|
||||
// 不同向量空间/模型的条目不得参与:坐标系不同,余弦无意义。
|
||||
foreign := frames[0]
|
||||
if err := s.SetVec(foreign, []float64{1, 0, 0, 0}, "other-space"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err = s.QueryMediaScored([]float64{1, 0, 0, 0}, "space", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, h := range hits {
|
||||
if h.Item.Digest == foreign {
|
||||
t.Fatal("另一套空间(other-space)的向量不该被 space 查询召回")
|
||||
}
|
||||
}
|
||||
|
||||
if hits, _ := s.Search("三色带", KindImage, 10); len(hits) != 1 {
|
||||
t.Fatalf("搜「三色带」应命中 1 条,实际 %d", len(hits))
|
||||
}
|
||||
if hits, _ := s.Search("计数器", KindImage, 10); len(hits) != 6 {
|
||||
t.Fatalf("搜「计数器」应命中 6 帧,实际 %d", len(hits))
|
||||
}
|
||||
pend, _ := s.Pending(100)
|
||||
if len(pend) != 0 {
|
||||
t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend))
|
||||
}
|
||||
_ = frames
|
||||
}
|
||||
|
||||
func TestSmoke_ArchiveTransfersOwnership(t *testing.T) {
|
||||
// 场景:L0 的 context 事件被 Prune 归档进 L2 文档,
|
||||
// 媒体引用需从 context owner 转到 document owner,期间内容不能被 GC 掉。
|
||||
func TestSmoke_ContentSurvivesLayerMigration(t *testing.T) {
|
||||
// 场景:同一份媒体随记忆块从 Context 迁移到 Document 再到 Graph。
|
||||
// 迁移的是块本身,digest 不变,因此内容在整条链路上始终可读。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
png := makePNG(400, 400, 0)
|
||||
d, _ := s.Put(png, Item{MIME: "image/png", Tool: "multimodal_see_picture"})
|
||||
for turn := 1; turn <= 5; turn++ {
|
||||
s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn))
|
||||
}
|
||||
|
||||
// evt-1 被淘汰,其内容归档为一篇文档
|
||||
n, err := s.DropOwner("context", "evt-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
// 迁移过程中该 digest 始终可读
|
||||
for _, layer := range []string{"context", "document", "graph"} {
|
||||
if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) {
|
||||
t.Fatalf("迁移到 %s 时内容应完好: %v", layer, err)
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("应注销 1 条引用,实际 %d", n)
|
||||
}
|
||||
if err := s.AddRef(d, "document", "doc_archived_001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
it, _ := s.Stat(d)
|
||||
if it.RefCount != 5 {
|
||||
t.Fatalf("引用转移后总数应仍为 5(4 context + 1 document),实际 %d", it.RefCount)
|
||||
}
|
||||
// 归档过程中内容必须始终可读
|
||||
if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) {
|
||||
t.Fatalf("归档后内容应完好: %v", err)
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_GCSweepsToolLeftovers(t *testing.T) {
|
||||
// 场景:别的工具(cmd_run 之类)产出的一次性图片没人引用,
|
||||
// 应被 GC 清掉;而被记忆引用的媒体一个都不能少。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
func TestSmoke_DeleteRemovesOnlyThatContent(t *testing.T) {
|
||||
// 场景:某个工具产出的一次性图片所在的记忆块被删除时,
|
||||
// 只有它自己的内容被删;其他块的内容一个都不能少。
|
||||
s := newTestStore(t)
|
||||
|
||||
keep, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"})
|
||||
s.AddRef(keep, "document", "doc-1")
|
||||
held, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"})
|
||||
var frames []string
|
||||
for i := 0; i < 6; i++ {
|
||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg"})
|
||||
s.AddRef(d, "context", "evt-video")
|
||||
frames = append(frames, d)
|
||||
}
|
||||
// 1000+i 保证与上面的帧、以及彼此都不重复
|
||||
var ephemeral []string
|
||||
for i := 0; i < 20; i++ {
|
||||
s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
d, _ := s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
ephemeral = append(ephemeral, d)
|
||||
}
|
||||
|
||||
before := s.Stats()["count"].(int)
|
||||
removed, freed, err := s.GC(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
for _, d := range ephemeral {
|
||||
if err := s.Delete(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
after := s.Stats()["count"].(int)
|
||||
if removed != 20 {
|
||||
t.Fatalf("应清 20 条孤儿,实际 %d", removed)
|
||||
}
|
||||
if after != before-20 {
|
||||
t.Fatalf("条目数应从 %d 降到 %d,实际 %d", before, before-20, after)
|
||||
}
|
||||
if _, err := s.Get(keep); err != nil {
|
||||
t.Fatalf("被文档引用的图被误删: %v", err)
|
||||
if _, err := s.Get(held); err != nil {
|
||||
t.Fatalf("被保留的内容被误删: %v", err)
|
||||
}
|
||||
for i, f := range frames {
|
||||
if _, err := s.Get(f); err != nil {
|
||||
t.Fatalf("第 %d 帧被误删: %v", i, err)
|
||||
}
|
||||
}
|
||||
t.Logf("GC: %d 条 → 清 %d 条(%d 字节)→ %d 条", before, removed, freed, after)
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
||||
// 端到端:入库 → 描述 → 引用 → GC → 重启 → 检索,
|
||||
// 端到端:入库 → 嵌入 → 删除一些内容 → 重启 → 向量检索,
|
||||
// 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir, 50*1024*1024)
|
||||
s, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
png := makePNG(400, 400, 0)
|
||||
pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"})
|
||||
s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm")
|
||||
s.AddRef(pic, "graph_sentence", "sent-42")
|
||||
s.SetVec(pic, []float64{1, 0, 0}, "space")
|
||||
for i := 0; i < 6; i++ {
|
||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
||||
s.Describe(d, fmt.Sprintf("视频第 %d 帧", i+1), "visionllm")
|
||||
s.AddRef(d, "context", "evt-video")
|
||||
s.SetVec(d, []float64{0, 1, float64(i)}, "space")
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
}
|
||||
if _, _, err := s.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
d, _ := s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
if err := s.Delete(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
beforeCount := s.Stats()["count"].(int)
|
||||
s.Close()
|
||||
|
||||
s2, err := New(dir, 50*1024*1024)
|
||||
s2, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("重开失败: %v", err)
|
||||
}
|
||||
@ -293,24 +254,24 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("重开后查不到: %v", err)
|
||||
}
|
||||
if it.Description == "" || it.RefCount != 1 {
|
||||
t.Fatalf("元数据未持久化: %+v", it)
|
||||
if len(it.Vec) != 3 || it.VecModel != "space" {
|
||||
t.Fatalf("向量未持久化: %+v", it)
|
||||
}
|
||||
data, err := s2.Get(pic)
|
||||
if err != nil || !bytes.Equal(data, png) {
|
||||
t.Fatalf("重开后内容不一致: %v", err)
|
||||
}
|
||||
if refs, _ := s2.Refs("context", "evt-video"); len(refs) != 6 {
|
||||
t.Fatalf("重开后视频帧引用应为 6,实际 %d", len(refs))
|
||||
hits, err := s2.QueryMediaScored([]float64{1, 0, 0}, "space", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits, _ := s2.Search("三色带", KindImage, 10); len(hits) != 1 {
|
||||
t.Fatal("重开后描述应仍可检索")
|
||||
if len(hits) == 0 || hits[0].Item.Digest != pic {
|
||||
t.Fatal("重开后向量检索应仍能命中")
|
||||
}
|
||||
|
||||
// 磁盘文件数 == 元数据条数:无「元数据在文件没了」也无「文件在元数据没了」
|
||||
if n := blobFileCount(t, s2); n != beforeCount {
|
||||
t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount)
|
||||
}
|
||||
checkRefIntegrity(t, s2)
|
||||
t.Logf("跨重启:%d 条目、描述与引用全部完好", beforeCount)
|
||||
t.Logf("跨重启:%d 条目、向量与内容全部完好", beforeCount)
|
||||
}
|
||||
|
||||
@ -12,14 +12,16 @@ import (
|
||||
|
||||
// TestSoak_SustainedMixedLoad 长稳测试:持续混合负载下不变量不破。
|
||||
// 用 -run TestSoak -timeout 300s 单独跑,默认 short 模式跳过。
|
||||
//
|
||||
// 媒体没有独立生命周期管理:blob 是记忆块的内容,块被删除时内容随之删除。
|
||||
func TestSoak_SustainedMixedLoad(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("long soak test; run with -run TestSoak")
|
||||
}
|
||||
dur := 60 * time.Second
|
||||
s := newTestStore(t, 8*1024*1024) // 8MB 上限,逼 GC 频繁工作
|
||||
s := newTestStore(t)
|
||||
|
||||
// 常驻受保护集
|
||||
// 常驻受保护区:全程被记忆块持有,模拟 Graph L3 中的块
|
||||
const keepN = 20
|
||||
keep := make([]string, keepN)
|
||||
keepData := make([][]byte, keepN)
|
||||
@ -31,16 +33,13 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AddRef(dg, "graph_sentence", fmt.Sprintf("s-%d", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep[i] = dg
|
||||
keepData[i] = d
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
var puts, gets, gcs, describes, searches, refOps atomic.Int64
|
||||
var puts, gets, deletes, embeds, searches atomic.Int64
|
||||
var fatal atomic.Int64
|
||||
|
||||
worker := func(name string, fn func(iter int) error) {
|
||||
@ -62,29 +61,17 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
||||
}()
|
||||
}
|
||||
|
||||
// 写入者 ×3
|
||||
// 写入者 ×3:持续写入一次性内容(无人持有)
|
||||
for w := 0; w < 3; w++ {
|
||||
wid := w
|
||||
worker(fmt.Sprintf("put-%d", wid), func(i int) error {
|
||||
b := make([]byte, 2048)
|
||||
rand.Read(b)
|
||||
b = append([]byte(fmt.Sprintf("eph-%d-%d-", wid, i)), b...)
|
||||
d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
if err != nil {
|
||||
if _, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"}); err != nil {
|
||||
return err
|
||||
}
|
||||
puts.Add(1)
|
||||
// 三分之一挂上引用再立刻注销,模拟短命引用
|
||||
if i%3 == 0 {
|
||||
own := fmt.Sprintf("tmp-%d-%d", wid, i)
|
||||
if err := s.AddRef(d, "context", own); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.DropRef(d, "context", own); err != nil {
|
||||
return err
|
||||
}
|
||||
refOps.Add(2)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@ -105,35 +92,43 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// GC 者
|
||||
worker("gc", func(i int) error {
|
||||
if _, _, err := s.GC(0); err != nil {
|
||||
return err
|
||||
}
|
||||
gcs.Add(1)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
|
||||
// 描述者
|
||||
worker("describe", func(i int) error {
|
||||
pend, err := s.Pending(5)
|
||||
// 删除者:持续删除一次性内容(模拟块创建后又被遗忘)
|
||||
worker("delete", func(i int) error {
|
||||
b := make([]byte, 2048)
|
||||
rand.Read(b)
|
||||
b = append([]byte(fmt.Sprintf("del-%d-", i)), b...)
|
||||
d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range pend {
|
||||
// 忽略 unknown digest:GC 可能在 Pending 与 Describe 之间清掉它,
|
||||
// 这是正常竞态而非缺陷。
|
||||
_ = s.Describe(it.Digest, fmt.Sprintf("描述 %d 含图表与文字", i), "vis")
|
||||
describes.Add(1)
|
||||
if err := s.Delete(d); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
deletes.Add(1)
|
||||
time.Sleep(time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
|
||||
// 向量写入者:持续给新内容嵌入并删除(模拟启动迁移/短命媒体)
|
||||
worker("embed", func(i int) error {
|
||||
b := make([]byte, 1024)
|
||||
rand.Read(b)
|
||||
b = append([]byte(fmt.Sprintf("emb-%d-", i)), b...)
|
||||
d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SetVec(d, []float64{1, float64(i % 7)}, "soak-space"); err != nil {
|
||||
return err
|
||||
}
|
||||
embeds.Add(1)
|
||||
time.Sleep(time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
|
||||
// 检索者
|
||||
worker("search", func(i int) error {
|
||||
if _, err := s.Search("图表", KindImage, 20); err != nil {
|
||||
if _, err := s.QueryMediaScored([]float64{1, 0}, "soak-space", 20); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.Stat(keep[i%keepN]); err != nil {
|
||||
@ -152,8 +147,8 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
||||
t.Fatalf("%d 个 worker 报致命错误", n)
|
||||
}
|
||||
|
||||
t.Logf("%v 内: put=%d get=%d gc=%d describe=%d search=%d refOps=%d",
|
||||
dur, puts.Load(), gets.Load(), gcs.Load(), describes.Load(), searches.Load(), refOps.Load())
|
||||
t.Logf("%v 内: put=%d get=%d delete=%d embed=%d search=%d",
|
||||
dur, puts.Load(), gets.Load(), deletes.Load(), embeds.Load(), searches.Load())
|
||||
|
||||
// 收尾断言
|
||||
for i, d := range keep {
|
||||
@ -164,17 +159,8 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
|
||||
if !bytes.Equal(got, keepData[i]) {
|
||||
t.Fatalf("受保护项内容变了 %s", shortDigest(d))
|
||||
}
|
||||
it, err := s.Stat(d)
|
||||
if err != nil || it.RefCount != 1 {
|
||||
t.Fatalf("受保护项引用计数应为 1: %+v", it)
|
||||
}
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
|
||||
st := s.Stats()
|
||||
t.Logf("收尾: 条目=%v 字节=%v 未引用=%v 已描述=%v",
|
||||
st["count"], st["total_bytes"], st["unreferenced"], st["described"])
|
||||
if total := st["total_bytes"].(int64); total > 8*1024*1024*3 {
|
||||
t.Fatalf("容量失控: %d 远超上限", total)
|
||||
}
|
||||
t.Logf("收尾: 条目=%v 字节=%v 类型=%v", st["count"], st["total_bytes"], st["by_kind"])
|
||||
}
|
||||
|
||||
@ -16,10 +16,12 @@ import (
|
||||
// 压力测试与冒烟测试。
|
||||
//
|
||||
// 关注点不是吞吐数字,而是并发下的不变量是否被破坏:
|
||||
// 1. ref_count 与 media_refs 表的行数必须始终一致(错位会让 GC 误删或永不清)
|
||||
// 2. GC 与读写并发时,有引用的内容绝不能被删
|
||||
// 3. 同内容并发 Put 只落一份磁盘、digest 一致
|
||||
// 4. SQLite 在多 goroutine 下不出现 "database is locked"
|
||||
// 1. GC 与读写并发时,被记忆块持有的内容绝不能被删
|
||||
// 2. 同内容并发 Put 只落一份磁盘、digest 一致
|
||||
// 3. SQLite 在多 goroutine 下不出现 "database is locked"
|
||||
//
|
||||
// 存活判定不再依赖 media_refs/ref_count:调用方把「三层记忆当前持有的
|
||||
// digest 集合」传给 GC,本层只做 CAS。
|
||||
|
||||
func randBytes(t *testing.T, n int) []byte {
|
||||
t.Helper()
|
||||
@ -30,37 +32,6 @@ func randBytes(t *testing.T, n int) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
// checkRefIntegrity 校验核心不变量:每个 digest 的 ref_count 等于
|
||||
// media_refs 里指向它的行数。这条对不上就意味着 GC 的判断依据是错的。
|
||||
func checkRefIntegrity(t *testing.T, s *Store) {
|
||||
t.Helper()
|
||||
rows, err := s.db.Query(`
|
||||
SELECT m.digest, m.ref_count, COUNT(r.digest)
|
||||
FROM media m LEFT JOIN media_refs r ON m.digest = r.digest
|
||||
GROUP BY m.digest, m.ref_count`)
|
||||
if err != nil {
|
||||
t.Fatalf("integrity query: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var bad int
|
||||
for rows.Next() {
|
||||
var d string
|
||||
var stored, actual int
|
||||
if err := rows.Scan(&d, &stored, &actual); err != nil {
|
||||
continue
|
||||
}
|
||||
if stored != actual {
|
||||
bad++
|
||||
if bad <= 5 {
|
||||
t.Errorf("ref 计数错位 %s: ref_count=%d 实际引用行=%d", shortDigest(d), stored, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
if bad > 0 {
|
||||
t.Fatalf("共 %d 条 digest 的 ref_count 与 media_refs 不一致", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// blobFileCount 统计 CAS 目录下的实际文件数(不含 .tmp)。
|
||||
func blobFileCount(t *testing.T, s *Store) int {
|
||||
t.Helper()
|
||||
@ -117,7 +88,6 @@ func TestStress_ConcurrentPutSameContent(t *testing.T) {
|
||||
if got, err := s.Get(first); err != nil || !bytes.Equal(got, data) {
|
||||
t.Fatalf("内容应可完整读回: err=%v len=%d", err, len(got))
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestStress_ConcurrentPutDistinctContent(t *testing.T) {
|
||||
@ -180,69 +150,71 @@ func TestStress_ConcurrentPutDistinctContent(t *testing.T) {
|
||||
if st["count"].(int) != len(records) {
|
||||
t.Fatalf("库内条目应为 %d,实际 %v", len(records), st["count"])
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestStress_ConcurrentRefChurn(t *testing.T) {
|
||||
// 引用增删风暴:多 owner 对少量 digest 反复 AddRef/DropRef。
|
||||
// 核心断言是最终 ref_count 与 media_refs 行数一致——错位就意味着
|
||||
// GC 会误删(计数偏低)或永不清(计数虚高)。
|
||||
s := newTestStore(t, 0)
|
||||
func TestStress_ConcurrentDeleteAndPut(t *testing.T) {
|
||||
// 删除与写入并发:核心断言是被保留的内容永远可读,
|
||||
// 删除只影响目标 digest,不误伤其他内容。
|
||||
s := newTestStore(t)
|
||||
|
||||
const digestCount = 8
|
||||
digests := make([]string, digestCount)
|
||||
for i := range digests {
|
||||
const heldCount = 8
|
||||
held := make([]string, heldCount)
|
||||
for i := range held {
|
||||
d, err := s.Put([]byte(fmt.Sprintf("payload-%d", i)), Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digests[i] = d
|
||||
held[i] = d
|
||||
}
|
||||
|
||||
const workers = 24
|
||||
const rounds = 40
|
||||
const workers = 16
|
||||
const rounds = 30
|
||||
var wg sync.WaitGroup
|
||||
var addErr, dropErr atomic.Int64
|
||||
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(wid int) {
|
||||
defer wg.Done()
|
||||
owner := fmt.Sprintf("evt-%d", wid)
|
||||
for r := 0; r < rounds; r++ {
|
||||
d := digests[(wid+r)%digestCount]
|
||||
if err := s.AddRef(d, "context", owner); err != nil {
|
||||
addErr.Add(1)
|
||||
d, err := s.Put([]byte(fmt.Sprintf("tmp-%d-%d", wid, r)), Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Errorf("Put: %v", err)
|
||||
return
|
||||
}
|
||||
// 故意重复 AddRef:幂等性在并发下也必须成立
|
||||
if err := s.AddRef(d, "context", owner); err != nil {
|
||||
addErr.Add(1)
|
||||
}
|
||||
if r%2 == 0 {
|
||||
if err := s.DropRef(d, "context", owner); err != nil {
|
||||
dropErr.Add(1)
|
||||
}
|
||||
if err := s.Delete(d); err != nil {
|
||||
t.Errorf("Delete: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
// 并发读取被保留内容
|
||||
for rdr := 0; rdr < 4; rdr++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for r := 0; r < rounds; r++ {
|
||||
for i, d := range held {
|
||||
if _, err := s.Get(d); err != nil {
|
||||
t.Errorf("内容 %d 被误删: %v", i, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if n := addErr.Load(); n > 0 {
|
||||
t.Fatalf("AddRef 失败 %d 次", n)
|
||||
for i, d := range held {
|
||||
if _, err := s.Get(d); err != nil {
|
||||
t.Fatalf("仍被保留的第 %d 项不可读: %v", i, err)
|
||||
}
|
||||
}
|
||||
if n := dropErr.Load(); n > 0 {
|
||||
t.Fatalf("DropRef 失败 %d 次", n)
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestStress_GCConcurrentWithWrites(t *testing.T) {
|
||||
// GC 与读写并发。最重要的断言:有引用的内容在整个过程中始终可读。
|
||||
// 这条一旦破,记忆里的 digest 就成了悬空指针。
|
||||
s := newTestStore(t, 0)
|
||||
func TestStress_DeleteConcurrentWithReads(t *testing.T) {
|
||||
// 删除与读取并发。最重要的断言:被保留的内容在整个过程中始终可读。
|
||||
s := newTestStore(t)
|
||||
|
||||
// 一批"受保护"的内容,全程持有引用
|
||||
const protectedCount = 10
|
||||
protected := make([]string, protectedCount)
|
||||
protectedData := make([][]byte, protectedCount)
|
||||
@ -252,9 +224,6 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AddRef(d, "document", fmt.Sprintf("doc-%d", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
protected[i] = d
|
||||
protectedData[i] = data
|
||||
}
|
||||
@ -262,10 +231,10 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
var readErr atomic.Int64
|
||||
var gcRuns atomic.Int64
|
||||
var deleteCount atomic.Int64
|
||||
var putCount atomic.Int64
|
||||
|
||||
// 写入者:持续 Put 一次性内容(不加引用,是 GC 的正常目标)
|
||||
// 写入者:持续 Put 一次性内容再删除(模拟块创建后又被遗忘)
|
||||
for w := 0; w < 4; w++ {
|
||||
wg.Add(1)
|
||||
go func(wid int) {
|
||||
@ -278,8 +247,13 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
|
||||
default:
|
||||
}
|
||||
data := append([]byte(fmt.Sprintf("ephemeral-%d-%d-", wid, i)), randBytes(t, 128)...)
|
||||
if _, err := s.Put(data, Item{MIME: "image/png"}); err == nil {
|
||||
putCount.Add(1)
|
||||
d, err := s.Put(data, Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
putCount.Add(1)
|
||||
if err := s.Delete(d); err == nil {
|
||||
deleteCount.Add(1)
|
||||
}
|
||||
i++
|
||||
}
|
||||
@ -314,33 +288,14 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
|
||||
}()
|
||||
}
|
||||
|
||||
// GC 者:minAge=0 让所有无引用项立刻可清,最大化与写入的冲突
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if _, _, err := s.GC(0); err != nil {
|
||||
t.Errorf("GC 报错: %v", err)
|
||||
return
|
||||
}
|
||||
gcRuns.Add(1)
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
if n := readErr.Load(); n > 0 {
|
||||
t.Fatalf("受保护内容读取失败 %d 次——GC 误删了有引用的项", n)
|
||||
t.Fatalf("受保护内容读取失败 %d 次", n)
|
||||
}
|
||||
t.Logf("并发窗口内: Put=%d GC=%d 轮", putCount.Load(), gcRuns.Load())
|
||||
t.Logf("并发窗口内: Put=%d Delete=%d", putCount.Load(), deleteCount.Load())
|
||||
|
||||
// 收尾确认:受保护的一个都没少
|
||||
for i, d := range protected {
|
||||
@ -348,17 +303,12 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
|
||||
if err != nil || !bytes.Equal(got, protectedData[i]) {
|
||||
t.Fatalf("收尾检查失败 %s: %v", shortDigest(d), err)
|
||||
}
|
||||
it, err := s.Stat(d)
|
||||
if err != nil || it.RefCount != 1 {
|
||||
t.Fatalf("受保护项引用计数应为 1: %+v err=%v", it, err)
|
||||
}
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
|
||||
// 描述写入与检索并发。C 部分的后台描述任务会长期这样跑。
|
||||
s := newTestStore(t, 0)
|
||||
func TestStress_SetVecConcurrentWithQuery(t *testing.T) {
|
||||
// 嵌入写入与向量检索并发(启动时的向量迁移就会长期这样跑)。
|
||||
s := newTestStore(t)
|
||||
const n = 60
|
||||
digests := make([]string, n)
|
||||
for i := range digests {
|
||||
@ -370,55 +320,52 @@ func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var descErr, searchErr atomic.Int64
|
||||
var writeErr, queryErr atomic.Int64
|
||||
|
||||
// 描述写入者
|
||||
// 向量写入者
|
||||
for w := 0; w < 4; w++ {
|
||||
wg.Add(1)
|
||||
go func(wid int) {
|
||||
defer wg.Done()
|
||||
for i := wid; i < n; i += 4 {
|
||||
desc := fmt.Sprintf("第 %d 张图,含蓝色图表与文字", i)
|
||||
if err := s.Describe(digests[i], desc, "vis-src"); err != nil {
|
||||
descErr.Add(1)
|
||||
vec := []float64{1, float64(i) / 100, 0}
|
||||
if err := s.SetVec(digests[i], vec, "space"); err != nil {
|
||||
writeErr.Add(1)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
// 检索者 + Pending 消费者
|
||||
// 检索者
|
||||
for r := 0; r < 3; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
if _, err := s.Search("图表", KindImage, 20); err != nil {
|
||||
searchErr.Add(1)
|
||||
}
|
||||
if _, err := s.Pending(10); err != nil {
|
||||
searchErr.Add(1)
|
||||
if _, err := s.QueryMediaScored([]float64{1, 0, 0}, "space", 20); err != nil {
|
||||
queryErr.Add(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if v := descErr.Load(); v > 0 {
|
||||
t.Fatalf("Describe 失败 %d 次", v)
|
||||
if v := writeErr.Load(); v > 0 {
|
||||
t.Fatalf("SetVec 失败 %d 次", v)
|
||||
}
|
||||
if v := searchErr.Load(); v > 0 {
|
||||
t.Fatalf("Search/Pending 失败 %d 次", v)
|
||||
if v := queryErr.Load(); v > 0 {
|
||||
t.Fatalf("QueryMediaScored 失败 %d 次", v)
|
||||
}
|
||||
|
||||
// 全部应已描述完
|
||||
pending, err := s.Pending(1000)
|
||||
// 全部应已嵌入,且都在同一空间
|
||||
stale, err := s.StaleVecDigestsAll("space")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("应全部描述完,仍有 %d 条未描述", len(pending))
|
||||
if len(stale) != 0 {
|
||||
t.Fatalf("应全部已嵌入,仍有 %d 条未嵌入", len(stale))
|
||||
}
|
||||
got, err := s.Search("图表", KindImage, 1000)
|
||||
got, err := s.QueryMediaScored([]float64{1, 0, 0}, "space", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -427,61 +374,46 @@ func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStress_CapacityGCUnderLoad(t *testing.T) {
|
||||
// 容量上限在持续写入下必须真正生效,且不碰有引用的项。
|
||||
const cap = 256 * 1024 // 256KB
|
||||
s := newTestStore(t, cap)
|
||||
func TestStress_DeleteUnderLoad(t *testing.T) {
|
||||
// 持续写入 + 删除下,被保留的项必须始终可读。
|
||||
s := newTestStore(t)
|
||||
|
||||
// 先放 3 个有引用的大项(合计约 96KB),它们永不可删
|
||||
const keepN = 3
|
||||
keep := make([]string, keepN)
|
||||
for i := range keep {
|
||||
keepList := make([]string, keepN)
|
||||
for i := range keepList {
|
||||
data := append([]byte(fmt.Sprintf("keep-%d-", i)), randBytes(t, 32*1024)...)
|
||||
d, err := s.Put(data, Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AddRef(d, "graph_sentence", fmt.Sprintf("sent-%d", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep[i] = d
|
||||
keepList[i] = d
|
||||
}
|
||||
|
||||
// 持续写入无引用内容,交替 GC
|
||||
for round := 0; round < 30; round++ {
|
||||
for i := 0; i < 3; i++ {
|
||||
data := append([]byte(fmt.Sprintf("tmp-%d-%d-", round, i)), randBytes(t, 16*1024)...)
|
||||
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil {
|
||||
d, err := s.Put(data, Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatalf("round %d Put: %v", round, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := s.GC(0); err != nil {
|
||||
t.Fatalf("round %d GC: %v", round, err)
|
||||
if err := s.Delete(d); err != nil {
|
||||
t.Fatalf("round %d Delete: %v", round, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
st := s.Stats()
|
||||
total := st["total_bytes"].(int64)
|
||||
t.Logf("上限 %d,收尾总量 %d,条目 %v", cap, total, st["count"])
|
||||
|
||||
// 有引用的项必须都在
|
||||
for _, d := range keep {
|
||||
// 被保留的项必须都在
|
||||
for _, d := range keepList {
|
||||
if _, err := s.Get(d); err != nil {
|
||||
t.Fatalf("有引用项被容量 GC 删了 %s: %v", shortDigest(d), err)
|
||||
t.Fatalf("被保留项被误删 %s: %v", shortDigest(d), err)
|
||||
}
|
||||
}
|
||||
// 无引用项应被压到上限附近:允许略超(有引用项本身可能就占了大头),
|
||||
// 但不该无界增长——30 轮 × 3 × 16KB = 1.4MB 若全留下就是失控。
|
||||
if total > cap*2 {
|
||||
t.Fatalf("容量 GC 未生效:总量 %d 远超上限 %d", total, cap)
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestStress_ReopenAfterHeavyChurn(t *testing.T) {
|
||||
// 大量写入 + GC 之后重开:元数据与磁盘不该出现互相不认的孤儿。
|
||||
// 大量写入 + 删除之后重开:元数据与磁盘不该出现互相不认的孤儿。
|
||||
dir := t.TempDir()
|
||||
s1, err := New(dir, 0)
|
||||
s1, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -494,19 +426,15 @@ func TestStress_ReopenAfterHeavyChurn(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i%5 == 0 {
|
||||
if err := s1.AddRef(d, "context", fmt.Sprintf("e-%d", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept = append(kept, d)
|
||||
} else if err := s1.Delete(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, _, err := s1.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforeStats := s1.Stats()
|
||||
s1.Close()
|
||||
|
||||
s2, err := New(dir, 0)
|
||||
s2, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("重开失败: %v", err)
|
||||
}
|
||||
@ -547,10 +475,9 @@ func TestStress_ReopenAfterHeavyChurn(t *testing.T) {
|
||||
|
||||
for _, d := range kept {
|
||||
if _, err := s2.Get(d); err != nil {
|
||||
t.Fatalf("有引用项重开后读不到 %s: %v", shortDigest(d), err)
|
||||
t.Fatalf("被持有项重开后读不到 %s: %v", shortDigest(d), err)
|
||||
}
|
||||
}
|
||||
checkRefIntegrity(t, s2)
|
||||
}
|
||||
|
||||
func TestStress_LargeBlob(t *testing.T) {
|
||||
@ -597,5 +524,4 @@ func TestStress_DataURLRoundTripAtScale(t *testing.T) {
|
||||
t.Fatalf("第 %d 次入库回读不一致: %v", i, err)
|
||||
}
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
193
internal/memory/migrate.go
Normal file
193
internal/memory/migrate.go
Normal file
@ -0,0 +1,193 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 旧媒体实体迁移。
|
||||
//
|
||||
// 历史上媒体进 L3 的方式是把正文 marker 反解成普通实体三元组:
|
||||
//
|
||||
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||
// → 实体「图片 a1b2c3d4e5f6」(type=Media) -内容-> 「一张紫蓝红三色带图」
|
||||
//
|
||||
// 这条路径把「媒体」伪装成实体 + 用生成的描述文本当语义索引,正是要废弃的
|
||||
// 将就机制。迁移做的事:把每条这类实体还原成原生记忆块,用
|
||||
// sentence --contains--> block 结构边挂到它当时所属的句子上,
|
||||
// 然后删掉旧实体与它的描述关系。块只按自己的向量被检索。
|
||||
//
|
||||
// 迁移是幂等的:实体处理完即删除,重复运行不会重复建块。
|
||||
|
||||
// legacyMediaDigestPattern 从旧媒体实体名尾部取出短 digest。
|
||||
// 名字形如「图片 a1b2c3d4e5f6」——旧实现刻意为每种模态加中文前缀。
|
||||
var legacyMediaDigestPattern = regexp.MustCompile(`([0-9a-f]{8,64})$`)
|
||||
|
||||
// LegacyMediaEntityDigest 从旧媒体实体名里取出短 digest,取不到返回空串。
|
||||
func LegacyMediaEntityDigest(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if !strings.Contains(name, " ") {
|
||||
return ""
|
||||
}
|
||||
m := legacyMediaDigestPattern.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// LegacyMediaResolver 把一个短 digest 解析成可用于 L3 的一等记忆块。
|
||||
// 解析失败(内容已不存在)返回 false,该实体将被直接删除而不建块。
|
||||
type LegacyMediaResolver func(shortDigest string) (MemoryBlock, bool)
|
||||
|
||||
// MigrateLegacyMediaEntities 把 marker 反解出来的旧媒体实体迁移成原生块。
|
||||
//
|
||||
// 返回迁移的块数与删除的旧实体数。任何一步失败都会回滚整个迁移,
|
||||
// 因为半途中断会留下既没有块也没有实体的句子——信息静默消失。
|
||||
func (g *GraphDB) MigrateLegacyMediaEntities(resolve LegacyMediaResolver) (blocks, entities int, err error) {
|
||||
if resolve == nil {
|
||||
return 0, 0, nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
rows, err := g.db.Query(`SELECT id, name FROM entities WHERE type = 'Media'`)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
type legacyEntity struct {
|
||||
id int64
|
||||
name string
|
||||
}
|
||||
var legacy []legacyEntity
|
||||
for rows.Next() {
|
||||
var e legacyEntity
|
||||
if err := rows.Scan(&e.id, &e.name); err != nil {
|
||||
rows.Close()
|
||||
return 0, 0, err
|
||||
}
|
||||
legacy = append(legacy, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, 0, err
|
||||
}
|
||||
rows.Close()
|
||||
if len(legacy) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// 同一份字节可能被多个旧实体引用(重复注入的同一张图),
|
||||
// 迁移后应指向同一个块:块的身份是内容,不是实体行。
|
||||
blockIDForDigest := make(map[string]string)
|
||||
|
||||
for _, e := range legacy {
|
||||
short := LegacyMediaEntityDigest(e.name)
|
||||
if short != "" {
|
||||
if block, ok := resolve(short); ok && block.PayloadDigest != "" {
|
||||
id, seen := blockIDForDigest[block.PayloadDigest]
|
||||
if !seen {
|
||||
if err := insertMigratedBlock(tx, block); err != nil {
|
||||
return 0, 0, fmt.Errorf("migrate legacy media %s: %w", short, err)
|
||||
}
|
||||
blockIDForDigest[block.PayloadDigest] = block.ID
|
||||
id = block.ID
|
||||
blocks++
|
||||
}
|
||||
n, err := attachBlockToLegacySentences(tx, e.id, id)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
_ = n
|
||||
}
|
||||
}
|
||||
// 无论能否解析出内容,旧实体与它的描述关系都必须删除:
|
||||
// 留着就等于继续用描述文本当媒体索引。
|
||||
if _, err := tx.Exec(`DELETE FROM relations WHERE source_id = ? OR target_id = ?`, e.id, e.id); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM entities WHERE id = ?`, e.id); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
entities++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return blocks, entities, nil
|
||||
}
|
||||
|
||||
// insertMigratedBlock 写一条迁移来的块(不经过 PutMemoryBlocks,避免重入锁)。
|
||||
func insertMigratedBlock(tx *sql.Tx, block MemoryBlock) error {
|
||||
if block.ID == "" {
|
||||
return fmt.Errorf("migrated block id is required")
|
||||
}
|
||||
if !validBlockModality(block.Modality) {
|
||||
return fmt.Errorf("migrated block %s has invalid modality %q", block.ID, block.Modality)
|
||||
}
|
||||
vectorJSON, err := json.Marshal(block.Vector)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
created := block.CreatedAt
|
||||
if created.IsZero() {
|
||||
created = time.Now()
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO memory_blocks (
|
||||
id, modality, text_content, payload_digest, mime, size, width, height,
|
||||
vector, fingerprint, source, tool, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME,
|
||||
block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
|
||||
block.Source, block.Tool, created, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
// attachBlockToLegacySentences 把迁移出的块挂到该旧实体当时所属的句子上,
|
||||
// 并保留那些句子(它们可能只有媒体关系,删实体后就再无关系引用)。
|
||||
func attachBlockToLegacySentences(tx *sql.Tx, entityID int64, blockID string) (int, error) {
|
||||
rows, err := tx.Query(`SELECT DISTINCT s.id FROM sentences s
|
||||
JOIN relations r ON r.sentence_id = s.id
|
||||
WHERE r.source_id = ? OR r.target_id = ?`, entityID, entityID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var sids []int64
|
||||
for rows.Next() {
|
||||
var sid int64
|
||||
if err := rows.Scan(&sid); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
sids = append(sids, sid)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
n := 0
|
||||
for _, sid := range sids {
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO memory_block_edges
|
||||
(source_kind, source_id, target_kind, target_id, edge_type)
|
||||
VALUES ('sentence', ?, 'block', ?, 'contains')`,
|
||||
fmt.Sprintf("%d", sid), blockID); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@ -14,8 +14,8 @@ import (
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/yanyiwu/gojieba"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"github.com/yanyiwu/gojieba"
|
||||
)
|
||||
|
||||
const downloadMaxWords = 200000
|
||||
@ -391,6 +391,13 @@ func (e *StaticEmbedder) Vectorize(text string) vector.Vector {
|
||||
return vec
|
||||
}
|
||||
|
||||
// EmbedImage 返回 ErrNotSupported:fastText 是纯文本词向量模型,
|
||||
// 没有视觉编码器。要用图像嵌入需要外部视觉模型(如 CLIP/MobileCLIP),
|
||||
// 那个由 config 里的 EmbeddingModelPath 指定的视觉模型负责。
|
||||
func (e *StaticEmbedder) EmbedImage(img []byte, mime string) (vector.Vector, error) {
|
||||
return nil, vector.ErrNotSupported
|
||||
}
|
||||
|
||||
func (e *StaticEmbedder) Dim() int {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
@ -108,7 +108,7 @@ func TestStaticEmbedderSemanticSimilarity(t *testing.T) {
|
||||
e := newSynthEmbedder(t, 300)
|
||||
|
||||
pairs := []struct {
|
||||
a, b string
|
||||
a, b string
|
||||
related bool
|
||||
}{
|
||||
{"今天天气怎么样", "明天会不会下雨", true},
|
||||
|
||||
51
internal/memory/vector/fuse.go
Normal file
51
internal/memory/vector/fuse.go
Normal file
@ -0,0 +1,51 @@
|
||||
package vector
|
||||
|
||||
import "math"
|
||||
|
||||
// FuseVectors 把同一统一空间里的多个向量融合为一个向量:
|
||||
// 逐维求和后重新 L2 归一化。
|
||||
//
|
||||
// 用途:文档/上下文事件既带文本、又带若干一等记忆块(图片/视频),
|
||||
// 二者的向量来自同一模型、同一 fingerprint、同一维度。融合后,
|
||||
// 一篇文档既能按文字、也能按它携带的图片内容被召回——
|
||||
// 图片由自己的向量参与检索,不依赖任何生成的描述文本。
|
||||
//
|
||||
// 约定:调用方传入的向量应已是 L2 归一化的同空间向量。长度不一致的
|
||||
// 向量会被跳过(不同模型/维度的残留);全空或全零返回 nil。
|
||||
func FuseVectors(vectors ...[]float64) []float64 {
|
||||
dim := 0
|
||||
for _, v := range vectors {
|
||||
if len(v) > dim {
|
||||
dim = len(v)
|
||||
}
|
||||
}
|
||||
if dim == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]float64, dim)
|
||||
used := 0
|
||||
for _, v := range vectors {
|
||||
if len(v) != dim {
|
||||
continue
|
||||
}
|
||||
for i, x := range v {
|
||||
out[i] += x
|
||||
}
|
||||
used++
|
||||
}
|
||||
if used == 0 {
|
||||
return nil
|
||||
}
|
||||
var norm float64
|
||||
for _, x := range out {
|
||||
norm += x * x
|
||||
}
|
||||
if norm == 0 {
|
||||
return nil
|
||||
}
|
||||
norm = math.Sqrt(norm)
|
||||
for i := range out {
|
||||
out[i] /= norm
|
||||
}
|
||||
return out
|
||||
}
|
||||
194
internal/memory/vector/http_embedder.go
Normal file
194
internal/memory/vector/http_embedder.go
Normal file
@ -0,0 +1,194 @@
|
||||
package vector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// http 也是一个普通 provider:核心只按名字打开它,不知道它背后是云 API、
|
||||
// 自建服务还是别的语言写的模型。
|
||||
embedding.Register("http", func(cfg embedding.Config) (embedding.Provider, error) {
|
||||
return NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: cfg.Options["endpoint"],
|
||||
APIKey: cfg.Options["api_key"],
|
||||
Model: cfg.Options["model"],
|
||||
Fingerprint: cfg.Options["fingerprint"],
|
||||
Dimension: atoiOrZero(cfg.Options["dimension"]),
|
||||
Timeout: durationOrZero(cfg.Options["timeout"]),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func atoiOrZero(s string) int {
|
||||
n := 0
|
||||
for _, r := range strings.TrimSpace(s) {
|
||||
if r < '0' || r > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func durationOrZero(s string) time.Duration {
|
||||
d, err := time.ParseDuration(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// HTTPEmbedderConfig 配置一个外部多模态向量服务。
|
||||
// 服务契约刻意很小:POST Endpoint,输入 modality/data/mime/side,返回 embedding。
|
||||
// 任何云 API 或自建服务只需适配这一个协议,即可复用内核全部向量存储与检索链路。
|
||||
type HTTPEmbedderConfig struct {
|
||||
Endpoint string
|
||||
APIKey string
|
||||
Model string
|
||||
Dimension int
|
||||
Timeout time.Duration
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
// HTTPEmbedder 是 MultimodalEmbedder 的外部 API 实现。
|
||||
type HTTPEmbedder struct {
|
||||
cfg HTTPEmbedderConfig
|
||||
client *http.Client
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
type httpEmbedRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Modality string `json:"modality"`
|
||||
Side string `json:"side"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
}
|
||||
|
||||
type httpEmbedResponse struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
Data []struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func NewHTTPEmbedder(cfg HTTPEmbedderConfig) (*HTTPEmbedder, error) {
|
||||
if strings.TrimSpace(cfg.Endpoint) == "" {
|
||||
return nil, fmt.Errorf("vector: empty HTTP embedding endpoint")
|
||||
}
|
||||
if cfg.Dimension <= 0 {
|
||||
return nil, fmt.Errorf("vector: invalid HTTP embedding dimension %d", cfg.Dimension)
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 30 * time.Second
|
||||
}
|
||||
if cfg.Fingerprint == "" {
|
||||
cfg.Fingerprint = "http:" + cfg.Model + fmt.Sprintf(":%d", cfg.Dimension)
|
||||
}
|
||||
return &HTTPEmbedder{cfg: cfg, client: &http.Client{Timeout: cfg.Timeout}}, nil
|
||||
}
|
||||
|
||||
func (e *HTTPEmbedder) VectorizeDense(text string) ([]float64, error) {
|
||||
return e.embed(context.Background(), httpEmbedRequest{Model: e.cfg.Model, Modality: string(ModalityText), Side: "query", Text: text})
|
||||
}
|
||||
|
||||
func (e *HTTPEmbedder) EmbedImageDense(img []byte, mime string) ([]float64, error) {
|
||||
return e.embed(context.Background(), httpEmbedRequest{Model: e.cfg.Model, Modality: string(ModalityImage), Side: "document", Data: base64.StdEncoding.EncodeToString(img), MIME: mime})
|
||||
}
|
||||
|
||||
func (e *HTTPEmbedder) embed(ctx context.Context, payload httpEmbedRequest) ([]float64, error) {
|
||||
e.mu.Lock()
|
||||
closed := e.closed
|
||||
e.mu.Unlock()
|
||||
if closed {
|
||||
return nil, fmt.Errorf("vector: HTTP embedder closed")
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.cfg.Endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if e.cfg.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+e.cfg.APIKey)
|
||||
}
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vector: HTTP embedding request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("vector: HTTP embedding status %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
var out httpEmbedResponse
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, fmt.Errorf("vector: decode HTTP embedding: %w", err)
|
||||
}
|
||||
v := out.Embedding
|
||||
if len(v) == 0 && len(out.Data) > 0 {
|
||||
v = out.Data[0].Embedding
|
||||
}
|
||||
if len(v) != e.cfg.Dimension {
|
||||
return nil, fmt.Errorf("vector: HTTP embedding dimension %d, want %d", len(v), e.cfg.Dimension)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (e *HTTPEmbedder) Fingerprint() string { return e.cfg.Fingerprint }
|
||||
func (e *HTTPEmbedder) Dim() int { return e.cfg.Dimension }
|
||||
|
||||
// Embed 实现公共 provider 契约:核心只传模态与不透明字节,本实现负责把它
|
||||
// 翻译成外部服务的协议。
|
||||
func (e *HTTPEmbedder) Embed(ctx context.Context, in embedding.Input) ([]float64, error) {
|
||||
req := httpEmbedRequest{
|
||||
Model: e.cfg.Model,
|
||||
Modality: string(in.Modality),
|
||||
Side: string(in.Purpose),
|
||||
Text: in.Text,
|
||||
MIME: in.MIME,
|
||||
}
|
||||
if in.Modality != embedding.ModalityText {
|
||||
req.Data = base64.StdEncoding.EncodeToString(in.Data)
|
||||
}
|
||||
return e.embed(ctx, req)
|
||||
}
|
||||
|
||||
// Info 声明本 provider 的向量空间身份。外部服务的支持模态无法在本地探测,
|
||||
// 因此只声明 text/image 这两条内核真正会走到的路径。
|
||||
func (e *HTTPEmbedder) Info() embedding.Info {
|
||||
return embedding.Info{
|
||||
Dimension: e.cfg.Dimension,
|
||||
Fingerprint: e.cfg.Fingerprint,
|
||||
Modalities: []embedding.Modality{embedding.ModalityText, embedding.ModalityImage},
|
||||
}
|
||||
}
|
||||
func (e *HTTPEmbedder) Loaded() bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return !e.closed
|
||||
}
|
||||
func (e *HTTPEmbedder) Close() {
|
||||
e.mu.Lock()
|
||||
e.closed = true
|
||||
e.mu.Unlock()
|
||||
}
|
||||
178
internal/memory/vector/http_embedder_test.go
Normal file
178
internal/memory/vector/http_embedder_test.go
Normal file
@ -0,0 +1,178 @@
|
||||
package vector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTTPEmbedder_RequiresEndpoint(t *testing.T) {
|
||||
_, err := NewHTTPEmbedder(HTTPEmbedderConfig{Dimension: 512})
|
||||
if err == nil {
|
||||
t.Fatal("应拒绝空 endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_RequiresDimension(t *testing.T) {
|
||||
_, err := NewHTTPEmbedder(HTTPEmbedderConfig{Endpoint: "http://localhost"})
|
||||
if err == nil {
|
||||
t.Fatal("应拒绝 dimension<=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_TextEmbedding(t *testing.T) {
|
||||
// 模拟返回 4 维向量的外部服务
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("期望 POST,实际 %s", r.Method)
|
||||
}
|
||||
var req httpEmbedRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Modality != "text" {
|
||||
t.Errorf("期望 modality=text,实际 %s", req.Modality)
|
||||
}
|
||||
if req.Text == "" {
|
||||
t.Fatal("text 不应为空")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"embedding":[0.1,0.2,0.3,0.4]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: srv.URL,
|
||||
Dimension: 4,
|
||||
Model: "test-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.Close()
|
||||
|
||||
if !e.Loaded() {
|
||||
t.Fatal("应处于 loaded 状态")
|
||||
}
|
||||
|
||||
vec, err := e.VectorizeDense("hello world")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vec) != 4 || vec[0] != 0.1 || vec[3] != 0.4 {
|
||||
t.Errorf("向量不符合预期: %v", vec)
|
||||
}
|
||||
if e.Fingerprint() != "http:test-model:4" {
|
||||
t.Errorf("指纹不符合预期: %s", e.Fingerprint())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_ImageEmbedding(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req httpEmbedRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Modality != "image" {
|
||||
t.Errorf("期望 modality=image,实际 %s", req.Modality)
|
||||
}
|
||||
if req.MIME != "image/png" {
|
||||
t.Errorf("期望 mime=image/png,实际 %s", req.MIME)
|
||||
}
|
||||
w.Write([]byte(`{"embedding":[0.5,0.5,0.5]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: srv.URL,
|
||||
Dimension: 3,
|
||||
Model: "img-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.Close()
|
||||
|
||||
vec, err := e.EmbedImageDense([]byte("fake-png-data"), "image/png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vec) != 3 {
|
||||
t.Errorf("期望 3 维,实际 %d", len(vec))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_CustomFingerprint(t *testing.T) {
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: "http://localhost:1234",
|
||||
Dimension: 512,
|
||||
Fingerprint: "jina-v5-omni-nano:2026",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.Close()
|
||||
if e.Fingerprint() != "jina-v5-omni-nano:2026" {
|
||||
t.Errorf("自定义指纹未生效: %s", e.Fingerprint())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_DimensionMismatchReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"embedding":[1,2]}`)) // 返回 2 维,配置期望 4
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: srv.URL,
|
||||
Dimension: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.Close()
|
||||
|
||||
_, err = e.VectorizeDense("test")
|
||||
if err == nil {
|
||||
t.Fatal("维度不匹配时应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_ServerErrorReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
w.Write([]byte("gateway down"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: srv.URL,
|
||||
Dimension: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.Close()
|
||||
|
||||
_, err = e.VectorizeDense("test")
|
||||
if err == nil {
|
||||
t.Fatal("服务端错误时应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_ClosePreventsFurtherCalls(t *testing.T) {
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: "http://localhost:1234",
|
||||
Dimension: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.Close()
|
||||
if e.Loaded() {
|
||||
t.Fatal("关闭后 Loaded() 应返回 false")
|
||||
}
|
||||
_, err = e.VectorizeDense("test")
|
||||
if err == nil {
|
||||
t.Fatal("关闭后应返回错误")
|
||||
}
|
||||
}
|
||||
95
internal/memory/vector/provider_adapter.go
Normal file
95
internal/memory/vector/provider_adapter.go
Normal file
@ -0,0 +1,95 @@
|
||||
package vector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
)
|
||||
|
||||
// ProviderAdapter translates the public model-neutral embedding.Provider SPI
|
||||
// to the small internal interface used by the existing memory consumers.
|
||||
// Model selection, media decoding, preprocessing, and runtime details remain
|
||||
// entirely inside the selected provider.
|
||||
type ProviderAdapter struct {
|
||||
provider embedding.Provider
|
||||
info embedding.Info
|
||||
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
// AdaptProvider validates and wraps a public provider for internal memory use.
|
||||
func AdaptProvider(provider embedding.Provider) (*ProviderAdapter, error) {
|
||||
info := provider.Info()
|
||||
if err := embedding.ValidateInfo(info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProviderAdapter{provider: provider, info: info}, nil
|
||||
}
|
||||
|
||||
func (a *ProviderAdapter) VectorizeDense(text string) ([]float64, error) {
|
||||
return a.embed(embedding.Input{
|
||||
Modality: embedding.ModalityText,
|
||||
Purpose: embedding.PurposeQuery,
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ProviderAdapter) EmbedImageDense(data []byte, mime string) ([]float64, error) {
|
||||
return a.embed(embedding.Input{
|
||||
Modality: embedding.ModalityImage,
|
||||
Purpose: embedding.PurposeDocument,
|
||||
Data: data,
|
||||
MIME: mime,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ProviderAdapter) embed(input embedding.Input) ([]float64, error) {
|
||||
a.mu.RLock()
|
||||
closed := a.closed
|
||||
a.mu.RUnlock()
|
||||
if closed {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
vec, err := a.provider.Embed(context.Background(), input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := embedding.ValidateVector(vec, a.info.Dimension); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return vec, nil
|
||||
}
|
||||
|
||||
func (a *ProviderAdapter) Fingerprint() string { return a.info.Fingerprint }
|
||||
func (a *ProviderAdapter) Dim() int { return a.info.Dimension }
|
||||
|
||||
// Modalities 报告该空间支持的输入模态(text/image/...)。
|
||||
//
|
||||
// 模态是**可选能力**:MultimodalEmbedder 契约里没有它,状态查询按接口断言取用,
|
||||
// 所以这里既不改公开接口,也不影响其它实现(核心也不硬编码任何模型名)。
|
||||
func (a *ProviderAdapter) Modalities() []string {
|
||||
out := make([]string, 0, len(a.info.Modalities))
|
||||
for _, m := range a.info.Modalities {
|
||||
out = append(out, string(m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *ProviderAdapter) Loaded() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return !a.closed
|
||||
}
|
||||
|
||||
func (a *ProviderAdapter) Close() {
|
||||
a.mu.Lock()
|
||||
if a.closed {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
a.closed = true
|
||||
a.mu.Unlock()
|
||||
a.provider.Close()
|
||||
}
|
||||
115
internal/memory/vector/provider_adapter_test.go
Normal file
115
internal/memory/vector/provider_adapter_test.go
Normal file
@ -0,0 +1,115 @@
|
||||
package vector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
)
|
||||
|
||||
// recordingProvider 记录核心传给 provider 的原始请求,用来断言
|
||||
// 「核心不解释内容、只搬字节」这一契约。
|
||||
type recordingProvider struct {
|
||||
got []embedding.Input
|
||||
dim int
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (p *recordingProvider) Embed(_ context.Context, in embedding.Input) ([]float64, error) {
|
||||
p.got = append(p.got, in)
|
||||
return make([]float64, p.dim), nil
|
||||
}
|
||||
|
||||
func (p *recordingProvider) Info() embedding.Info {
|
||||
return embedding.Info{Dimension: p.dim, Fingerprint: "recording:1"}
|
||||
}
|
||||
|
||||
func (p *recordingProvider) Close() { p.closed = true }
|
||||
|
||||
func TestProviderAdapterPassesOpaqueDataUnchanged(t *testing.T) {
|
||||
inner := &recordingProvider{dim: 3}
|
||||
adapted, err := AdaptProvider(inner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapted.Close()
|
||||
|
||||
// 核心把媒体当作不透明字节搬运:既不解码也不改字节。
|
||||
raw := []byte{0x89, 'P', 'N', 'G', 0x00, 0xff}
|
||||
if _, err := adapted.EmbedImageDense(raw, "image/png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := inner.got[0]
|
||||
if string(got.Data) != string(raw) {
|
||||
t.Fatalf("provider 收到的字节被改动: %v", got.Data)
|
||||
}
|
||||
if got.Modality != embedding.ModalityImage || got.MIME != "image/png" {
|
||||
t.Fatalf("模态/MIME 未原样传递: %+v", got)
|
||||
}
|
||||
if got.Purpose != embedding.PurposeDocument {
|
||||
t.Fatalf("用途应为 document: %q", got.Purpose)
|
||||
}
|
||||
|
||||
if _, err := adapted.VectorizeDense("hello"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inner.got[1].Modality != embedding.ModalityText || inner.got[1].Text != "hello" {
|
||||
t.Fatalf("文本请求不正确: %+v", inner.got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderAdapterRejectsWrongDimensionFromProvider(t *testing.T) {
|
||||
// provider 声明 3 维却返回 2 维:必须在进入存储前被拦下,
|
||||
// 否则一个维度错的向量会污染整个余弦检索。
|
||||
bad := &badDimProvider{}
|
||||
adapted, err := AdaptProvider(bad)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapted.Close()
|
||||
if _, err := adapted.VectorizeDense("x"); err == nil {
|
||||
t.Fatal("维度不符时应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
type badDimProvider struct{}
|
||||
|
||||
func (badDimProvider) Embed(context.Context, embedding.Input) ([]float64, error) {
|
||||
return []float64{1, 2}, nil
|
||||
}
|
||||
func (badDimProvider) Info() embedding.Info {
|
||||
return embedding.Info{Dimension: 3, Fingerprint: "bad:1"}
|
||||
}
|
||||
func (badDimProvider) Close() {}
|
||||
|
||||
func TestProviderAdapterCloseIsIdempotentAndStopsUse(t *testing.T) {
|
||||
inner := &recordingProvider{dim: 2}
|
||||
adapted, err := AdaptProvider(inner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapted.Close()
|
||||
adapted.Close() // 重复关闭不应 panic 或二次 Close provider
|
||||
if !inner.closed {
|
||||
t.Fatal("Close 未传递到 provider")
|
||||
}
|
||||
if _, err := adapted.VectorizeDense("x"); err == nil {
|
||||
t.Fatal("关闭后应拒绝调用")
|
||||
}
|
||||
if adapted.Loaded() {
|
||||
t.Fatal("关闭后 Loaded() 应为 false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModalityUnsupportedSentinelIsShared(t *testing.T) {
|
||||
// 内核侧的哨兵与公共契约的哨兵必须是同一个:provider 返回公共哨兵时,
|
||||
// 内核仍能用自己原有的名字识别。
|
||||
if !errors.Is(ErrModalityUnsupported, embedding.ErrUnsupportedModality) {
|
||||
t.Fatal("vector.ErrModalityUnsupported 与 embedding.ErrUnsupportedModality 未打通")
|
||||
}
|
||||
wrapped := errors.Join(embedding.ErrUnsupportedModality, errors.New("audio/wav"))
|
||||
if !errors.Is(wrapped, ErrModalityUnsupported) {
|
||||
t.Fatal("包装后的错误无法用内核哨兵识别")
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,73 @@
|
||||
package vector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
)
|
||||
|
||||
// Vectorizer 接口:将文本转为向量
|
||||
//
|
||||
// 多模态嵌入新增可选的 EmbedImage:支持视觉嵌入的实现者覆写此方法,
|
||||
// 不支持的(TF-IDF 等)在默认实现里返回 ErrNotSupported。
|
||||
type Vectorizer interface {
|
||||
Vectorize(text string) Vector
|
||||
EmbedImage(img []byte, mime string) (Vector, error)
|
||||
}
|
||||
|
||||
// MultimodalEmbedder 是稠密多模态编码器的接口。
|
||||
//
|
||||
// 与 Vectorizer(稀疏词向量,供 TF-IDF/倒排检索)刻意区分:多模态模型产出的
|
||||
// 是共享稠密空间,直接用于 media.Store 的稠密余弦检索,
|
||||
// **不得**塞进文档/知识层的稀疏 vector.Store(会破坏倒排剪枝与 TF-IDF 语义)。
|
||||
//
|
||||
// 实现不限:可以是内嵌 ONNX,也可以是外部 HTTP 向量服务——
|
||||
// 内核只依赖本接口,两条路径共享同一套检索/存储基础设施。Fingerprint 是模型
|
||||
// 空间标识(如模型文件指纹),作为 vec_model 持久化用于切换后重算。
|
||||
type MultimodalEmbedder interface {
|
||||
VectorizeDense(text string) ([]float64, error)
|
||||
EmbedImageDense(img []byte, mime string) ([]float64, error)
|
||||
Fingerprint() string
|
||||
Dim() int
|
||||
Loaded() bool
|
||||
Close()
|
||||
}
|
||||
|
||||
// MultimodalModality 是统一向量空间支持的输入模态。
|
||||
// 现内核只消费 text/image;外部 API 路径可能扩展 audio/video,
|
||||
// 通过类型断言在接口外按需扩展,不破坏现有契约。
|
||||
type MultimodalModality string
|
||||
|
||||
const (
|
||||
ModalityText MultimodalModality = "text"
|
||||
ModalityImage MultimodalModality = "image"
|
||||
ModalityAudio MultimodalModality = "audio"
|
||||
ModalityVideo MultimodalModality = "video"
|
||||
)
|
||||
|
||||
// ErrNotSupported 表示 Vectorizer 不支持该原生模态;调用方不得以描述文本冒充其向量。
|
||||
var ErrNotSupported = fmt.Errorf("vectorizer does not support image embedding")
|
||||
|
||||
// ErrModalityUnsupported 表示该模态不在本统一向量空间的原生覆盖范围内。
|
||||
//
|
||||
// 它与普通错误语义不同:调用方应把它当作「这条媒体本空间永远不会有向量」
|
||||
// 而不是「这次失败了、下次重试」。绝不能拿另一个模型的向量顶替——那会把
|
||||
// 两套坐标系混进同一空间,检索出来的相似度没有任何意义。
|
||||
//
|
||||
// 它是公共 provider 契约里那个哨兵值的别名,两者 errors.Is 互通:
|
||||
// provider 在自己的包内返回 embedding.ErrUnsupportedModality 即可,
|
||||
// 内核侧的判断无需改变。
|
||||
var ErrModalityUnsupported = embedding.ErrUnsupportedModality
|
||||
|
||||
// 注:曾经这里还有一个可选的 VideoEmbedder 接口(用类型断言探测视频能力)。
|
||||
// 已删除:那让核心为每一个新模态长出一套模型专属方法,正是“核心适配模型”的
|
||||
// 坏味道。模态能力现在是数据(embedding.Info.Modalities),输入是不透明的
|
||||
// Data+MIME(见 pkg/embedding)。
|
||||
|
||||
// Vector 是带权特征映射:feature → weight
|
||||
type Vector map[string]float64
|
||||
|
||||
@ -21,6 +77,12 @@ type Store struct {
|
||||
docs []DocVector
|
||||
dim int
|
||||
index *InvertedIndex
|
||||
|
||||
// minScore 是候选分数下限。**必须按向量空间标定**:
|
||||
// 词向量/多模态余弦通常在 0.3~0.9,而 TF-IDF 余弦只有 0.0~0.2 ——
|
||||
// 用同一个阈值会把词法路的大量有效候选静默砍掉
|
||||
// (实测:知识库自检索 MRR 0.307 → 0.193 就是这么掉的)。
|
||||
minScore float64
|
||||
}
|
||||
|
||||
type DocVector struct {
|
||||
@ -30,9 +92,27 @@ type DocVector struct {
|
||||
Meta map[string]string
|
||||
}
|
||||
|
||||
// DefaultMinScore 是默认候选中选阈值(沿用历史行为)。
|
||||
const DefaultMinScore = 0.05
|
||||
|
||||
// MinScore 返回当前候选中选阈值(供接线处自证用的是哪个阈值)。
|
||||
func (s *Store) MinScore() float64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.minScore
|
||||
}
|
||||
|
||||
// SetMinScore 调整候选中选阈值(按向量空间标定,见 minScore 字段注释)。
|
||||
func (s *Store) SetMinScore(v float64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.minScore = v
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{
|
||||
index: NewInvertedIndex(),
|
||||
index: NewInvertedIndex(),
|
||||
minScore: DefaultMinScore,
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,6 +141,26 @@ func (s *Store) Remove(id string) {
|
||||
}
|
||||
|
||||
func (s *Store) Search(query Vector, topK int) []DocVector {
|
||||
hits := s.SearchScored(query, topK)
|
||||
if len(hits) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]DocVector, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Doc
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DocVectorHit 是一篇文档的相似度候选及其原始 cosine 分数。
|
||||
// 跨模态融合需要分数做归一化;纯排序的 Search 不暴露它。
|
||||
type DocVectorHit struct {
|
||||
Doc DocVector
|
||||
Score float64
|
||||
}
|
||||
|
||||
// SearchScored 与 Search 同语义,但返回带原始 cosine 分数的候选。
|
||||
func (s *Store) SearchScored(query Vector, topK int) []DocVectorHit {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@ -84,7 +184,7 @@ func (s *Store) Search(query Vector, topK int) []DocVector {
|
||||
for _, d := range s.docs {
|
||||
if d.ID == id {
|
||||
score := CosineSimilarity(query, d.Vector)
|
||||
if score > 0.05 {
|
||||
if score > s.minScore {
|
||||
results = append(results, scored{d, score})
|
||||
}
|
||||
break
|
||||
@ -100,9 +200,9 @@ func (s *Store) Search(query Vector, topK int) []DocVector {
|
||||
results = results[:topK]
|
||||
}
|
||||
|
||||
out := make([]DocVector, len(results))
|
||||
out := make([]DocVectorHit, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = r.doc
|
||||
out[i] = DocVectorHit{Doc: r.doc, Score: r.score}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -246,9 +346,24 @@ func CosineSimilarity(a, b Vector) float64 {
|
||||
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
|
||||
// DenseCosine 计算两个 []float64 稠密向量的余弦相似度。
|
||||
// 与 CosineSimilarity(稀疏 map)数学等价,但面向稠密多模态向量。
|
||||
func DenseCosine(a, b []float64) float64 {
|
||||
var dot, na, nb float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
na += a[i] * a[i]
|
||||
nb += b[i] * b[i]
|
||||
}
|
||||
if na == 0 || nb == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / math.Sqrt(na*nb)
|
||||
}
|
||||
|
||||
// InvertedIndex 倒排索引,加速向量搜索
|
||||
type InvertedIndex struct {
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
postings map[string]map[string]float64 // feature → {docID: weight}
|
||||
}
|
||||
|
||||
|
||||
@ -210,3 +210,29 @@ func BenchmarkExtractNGrams(b *testing.B) {
|
||||
extractNGrams(text, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// 候选中选阈值必须**按向量空间标定**:词向量/多模态余弦通常在 0.3~0.9,
|
||||
// 而 TF-IDF 余弦只有 0.0~0.2。用同一个阈值会把词法路的有效候选静默砍掉
|
||||
// (知识库自检索 MRR 0.307→0.193 就是这么掉的,且当时看不出任何报错)。
|
||||
func TestSearchScoredRespectsMinScore(t *testing.T) {
|
||||
// 构造一个低余弦候选:共享特征 "a",但两个向量几乎正交 → cosine ≈ 0.02
|
||||
st := NewStore()
|
||||
st.Insert("doc", "", Vector{"a": 1, "b": 1}, nil) // |doc| = √2
|
||||
query := Vector{"a": 0.02, "c": 100} // 与 doc 的点积 0.02
|
||||
|
||||
hits := st.SearchScored(query, 10)
|
||||
for _, h := range hits {
|
||||
if h.Score < DefaultMinScore {
|
||||
t.Fatalf("默认阈值 %.2f 不该返回 %.5f 的候选", DefaultMinScore, h.Score)
|
||||
}
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("该查询在默认阈值下应被过滤,实际返回 %d 条", len(hits))
|
||||
}
|
||||
|
||||
st.SetMinScore(0)
|
||||
hits = st.SearchScored(query, 10)
|
||||
if len(hits) != 1 || hits[0].Doc.ID != "doc" {
|
||||
t.Fatalf("阈值设为 0 后应召回低余弦候选,实际 %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,16 +9,22 @@ var (
|
||||
//
|
||||
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
|
||||
// 这是首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须
|
||||
// 用新版 plugindev 重编),故跃到主版本号。
|
||||
// 用新版 hmapdev 重编),故跃到主版本号。
|
||||
// 1.1.0:记忆系统支持二进制多媒体节点——CAS 媒体存储 + L0/L2/L3 贯通。
|
||||
// 1.1.1:多模态贯通**插件边界**。内核实现公开 SDK 1.1.0 新增的媒体接口
|
||||
// (doc.insertWithMedia、io.injectMedia / injectMediaSync /
|
||||
// injectInterruptMedia),并把 text/image/audio 三条输入路径归一成
|
||||
// 一条 processInput 主干。
|
||||
// 1.2.0:模型中立的多模态 provider SPI(pkg/embedding)——内核不再适配任何
|
||||
// 具体模型,Qwen 实现移到 providers/qwen3vl;插件运行协议升到 2
|
||||
// (统一共享内存区,fd3 布局改变,不支持滚动升级);并实现 SDK 1.2.0
|
||||
// 新增的注入行为标志位(InjectOptions:no_memory / context_policy)
|
||||
// 与 ChannelDef.ContextPolicy,使输入/排队注入/中断注入/同步注入都能
|
||||
// 声明「是否记入记忆」与「是否据此裁剪上下文」(默认都是否)。
|
||||
//
|
||||
// 发布分支上此值是**本条发布线当前的版本号**;main 上则是下一个未发布中版本
|
||||
//(见 docs/git-branching.md §2.1 与 §四)。
|
||||
Version = "1.1.1"
|
||||
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
|
||||
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
|
||||
Version = "1.2.2"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
@ -32,9 +38,24 @@ var (
|
||||
// SDKCompatibleVersion 是此内核可兼容的最高 SDK 版本(semver)。
|
||||
//
|
||||
// 1.1.0:本内核实现了 SDK 1.1.0 的全部新增方法。
|
||||
// 用 SDK 1.0.0 编的存量插件照旧可用——新增方法由**插件调用、内核实现**,
|
||||
// 不调就不受影响,无需重编。
|
||||
SDKCompatibleVersion = "1.1.0"
|
||||
// 1.2.0:本内核实现了 SDK 1.2.0 的全部新增方法(IOInjector 的六个 *Opts
|
||||
// 注入变体、InjectOptions、ChannelDef.ContextPolicy),因此声明为
|
||||
// 1.2.0。用 SDK 1.0.0/1.1.0 编的存量插件照旧可用——新增方法由
|
||||
// **插件调用、内核实现**,不调就不受影响,无需重编。
|
||||
SDKCompatibleVersion = "1.2.0"
|
||||
|
||||
// SourceURL 是本内核构建所对应的源码地址。
|
||||
//
|
||||
// AGPL-3.0 §13(Remote Network Interaction)要求:当你把修改过的版本
|
||||
// 作为网络服务提供出去时,必须给使用者提供取得 Corresponding Source 的机会。
|
||||
// WebUI 的状态页会把这个值渲染成可见链接,所以:
|
||||
//
|
||||
// ❗**修改后对外部署的分支必须把它改指向自己的源码仓库**,否则链接指向的
|
||||
// 不是你实际运行的那份代码,§13 的提供义务并未履行。
|
||||
//
|
||||
// 构建时可用 -ldflags 覆盖,无需改源码:
|
||||
// -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.SourceURL=<你的仓库>
|
||||
SourceURL = "https://gitcode.com/JianFeeeee/HomeAgent"
|
||||
)
|
||||
|
||||
// FullVersion 返回完整的版本字符串。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user